From 76b13e055bf8e8bcf76d256350931b4e17d2d2c0 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Fri, 5 Jun 2026 17:06:18 -0700 Subject: [PATCH 01/15] feat(core): optional distributed object cache for query results Add an opt-in read-through cache that sits beneath the per-request cache and above the database, so content and chrome (settings, menus, taxonomies) reads can be served from a fast key/value store instead of hitting D1/SQLite on every request. - New ObjectCache abstraction (interface + descriptor + virtual module + per-isolate backend), mirroring the storage adapter pattern. Off by default: when unconfigured, cachedQuery is a transparent passthrough. - Backends: in-isolate memory (emdash/object-cache/memory via memoryCache() from emdash/astro) and Cloudflare KV (@emdash-cms/cloudflare/cache/kv via kvCache()). - JSON codec preserves Date instances; content entries are snapshotted (dropping the .edit proxy, capturing the CURSOR_RAW_VALUES symbol) and rebuilt on read. - Epoch-based invalidation at the repository chokepoint (content, seo, byline, taxonomy, menu) and settings; content reads fold in shared bylines/taxonomies epochs so author/term renames invalidate correctly. - Auth/preview/edit-mode and isolated DBs always bypass. Existing sites are unaffected until they opt in. --- .changeset/object-cache.md | 30 ++ packages/cloudflare/package.json | 4 + packages/cloudflare/src/cache/kv.ts | 71 ++++ packages/cloudflare/src/index.ts | 61 ++- packages/cloudflare/tsdown.config.ts | 4 +- packages/core/package.json | 4 + packages/core/src/astro/index.ts | 6 + .../core/src/astro/integration/runtime.ts | 36 ++ .../src/astro/integration/virtual-modules.ts | 28 ++ .../core/src/astro/integration/vite-config.ts | 14 + .../core/src/astro/object-cache/adapters.ts | 50 +++ .../core/src/database/repositories/byline.ts | 13 + .../core/src/database/repositories/content.ts | 29 +- .../core/src/database/repositories/menu.ts | 14 +- .../core/src/database/repositories/seo.ts | 3 + .../src/database/repositories/taxonomy.ts | 14 +- packages/core/src/index.ts | 20 + packages/core/src/menus/index.ts | 15 +- packages/core/src/object-cache/codec.ts | 71 ++++ packages/core/src/object-cache/index.ts | 399 ++++++++++++++++++ packages/core/src/object-cache/memory.ts | 91 ++++ packages/core/src/object-cache/types.ts | 106 +++++ packages/core/src/query.ts | 187 +++++++- packages/core/src/settings/index.ts | 18 +- packages/core/src/taxonomies/index.ts | 135 +++--- packages/core/src/virtual-modules.d.ts | 11 + .../tests/unit/object-cache-content.test.ts | 130 ++++++ packages/core/tests/unit/object-cache.test.ts | 191 +++++++++ packages/core/tsdown.config.ts | 2 + 29 files changed, 1670 insertions(+), 87 deletions(-) create mode 100644 .changeset/object-cache.md create mode 100644 packages/cloudflare/src/cache/kv.ts create mode 100644 packages/core/src/astro/object-cache/adapters.ts create mode 100644 packages/core/src/object-cache/codec.ts create mode 100644 packages/core/src/object-cache/index.ts create mode 100644 packages/core/src/object-cache/memory.ts create mode 100644 packages/core/src/object-cache/types.ts create mode 100644 packages/core/tests/unit/object-cache-content.test.ts create mode 100644 packages/core/tests/unit/object-cache.test.ts diff --git a/.changeset/object-cache.md b/.changeset/object-cache.md new file mode 100644 index 0000000000..830ddb39b7 --- /dev/null +++ b/.changeset/object-cache.md @@ -0,0 +1,30 @@ +--- +"emdash": minor +"@emdash-cms/cloudflare": minor +--- + +Add an optional distributed object cache for query results. + +Content reads (`getEmDashCollection`, `getEmDashEntry`, `resolveEmDashPath`) and chrome reads (site settings, menus, taxonomies) can now be served from a fast key/value store instead of hitting the database on every request. This sits beneath the per-request cache and above the database, dramatically reducing read pressure on D1/SQLite — especially valuable on Cloudflare, where KV handles far more requests than D1. + +The cache is **off by default** and fully opt-in. Configure a backend in `astro.config.mjs`: + +```ts +import { kvCache } from "@emdash-cms/cloudflare"; // Workers KV (distributed) +import { memoryCache } from "emdash/astro"; // in-isolate (Node / local dev) + +emdash({ + database: d1({ binding: "DB" }), + objectCache: kvCache({ binding: "CACHE" }), +}); +``` + +with a matching KV binding in `wrangler.jsonc`: + +```jsonc +{ "kv_namespaces": [{ "binding": "CACHE", "id": "" }] } +``` + +Invalidation is epoch-based and automatic: content, byline, taxonomy, menu, and settings writes bump a per-namespace version, instantly orphaning stale entries (no key enumeration needed). Authenticated, preview, and visual-edit requests always bypass the cache, so editors see live content immediately; anonymous visitors may see content up to `revalidate` ms stale after an edit (default 1s, configurable). + +New public API: `cachedQuery`, `invalidateObjectCache`, `invalidateCollectionCache`, `contentNamespace`/`contentNamespaces`, `CacheNamespace`, the `ObjectCache*` types (from `emdash`), `memoryCache()` (from `emdash/astro`), and `kvCache()` (from `@emdash-cms/cloudflare`). Existing sites are unaffected until they opt in. diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index c6e6b4eef1..ff11bb8f7e 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -60,6 +60,10 @@ "./cache/config": { "types": "./dist/cache/config.d.mts", "default": "./dist/cache/config.mjs" + }, + "./cache/kv": { + "types": "./dist/cache/kv.d.mts", + "default": "./dist/cache/kv.mjs" } }, "scripts": { diff --git a/packages/cloudflare/src/cache/kv.ts b/packages/cloudflare/src/cache/kv.ts new file mode 100644 index 0000000000..ebfcd123d9 --- /dev/null +++ b/packages/cloudflare/src/cache/kv.ts @@ -0,0 +1,71 @@ +/** + * Cloudflare KV object-cache backend — RUNTIME ENTRY + * + * Backs EmDash's distributed object cache with a Workers KV namespace. KV is + * globally replicated and built for high read volume, making it the right + * place to absorb content/chrome reads that would otherwise hammer D1. + * + * This module imports `cloudflare:workers` to access the KV binding directly. + * Do NOT import it at config time — use `kvCache()` from + * `@emdash-cms/cloudflare` in `astro.config.mjs` instead. + * + * Wire it up: + * + * ```ts + * import { kvCache } from "@emdash-cms/cloudflare"; + * emdash({ objectCache: kvCache({ binding: "CACHE" }) }); + * ``` + * + * with a matching binding in `wrangler.jsonc`: + * + * ```jsonc + * { "kv_namespaces": [{ "binding": "CACHE", "id": "..." }] } + * ``` + */ + +import { env } from "cloudflare:workers"; +import type { CreateObjectCacheBackendFn, ObjectCacheBackend } from "emdash"; + +/** + * Workers KV enforces a 60-second floor on `expirationTtl`. Clamp shorter TTLs + * up rather than letting `put` throw — epoch-based invalidation already + * orphans stale keys immediately, so a slightly longer backstop TTL is benign. + */ +const KV_MIN_TTL_SECONDS = 60; + +export const createObjectCache: CreateObjectCacheBackendFn = (config): ObjectCacheBackend => { + const binding = typeof config.binding === "string" ? config.binding : ""; + if (!binding) { + throw new Error("KV object-cache requires a `binding` name in its config."); + } + + // `env` from cloudflare:workers has no index signature. + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- KVNamespace binding accessed from untyped env object + const kv = (env as Record)[binding] as KVNamespace | undefined; + if (!kv) { + throw new Error( + `KV binding "${binding}" not found. Add it to wrangler.jsonc:\n\n` + + `{\n "kv_namespaces": [{ "binding": "${binding}", "id": "" }]\n}\n\n` + + `and ensure you're running on Cloudflare Workers.`, + ); + } + + return { + async get(key: string): Promise { + return (await kv.get(key, "text")) ?? null; + }, + async set(key: string, value: string, ttlSeconds?: number): Promise { + if (ttlSeconds && ttlSeconds > 0) { + await kv.put(key, value, { + expirationTtl: Math.max(KV_MIN_TTL_SECONDS, Math.floor(ttlSeconds)), + }); + } else { + // No TTL: persistent key (used for epoch anchors). + await kv.put(key, value); + } + }, + async delete(key: string): Promise { + await kv.delete(key); + }, + }; +}; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 5009ae379c..bf8ab952b1 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -33,7 +33,12 @@ * ``` */ -import type { AuthDescriptor, DatabaseDescriptor, StorageDescriptor } from "emdash"; +import type { + AuthDescriptor, + DatabaseDescriptor, + ObjectCacheDescriptor, + StorageDescriptor, +} from "emdash"; import type { PreviewDOConfig } from "./db/do-types.js"; @@ -278,6 +283,60 @@ export function sandbox(): string { return "@emdash-cms/cloudflare/sandbox"; } +/** + * Cloudflare KV object-cache configuration. + */ +export interface KVCacheConfig { + /** Name of the KV binding in wrangler.jsonc. */ + binding: string; + /** + * Default TTL for cached entries, in seconds. Backstop for epoch-orphaned + * keys (KV clamps to a 60s minimum). Default 3600. + */ + defaultTtl?: number; + /** + * Cross-isolate staleness window in milliseconds: how long an isolate + * reuses a cached namespace epoch before re-reading it. Default 1000. + */ + revalidate?: number; + /** Prefix applied to every cache key (lets multiple sites share a namespace). */ + keyPrefix?: string; +} + +/** + * Cloudflare KV object-cache adapter. + * + * Backs EmDash's optional distributed object cache with a Workers KV + * namespace, offloading content and chrome reads from D1. Requires a KV + * binding in wrangler.jsonc. + * + * @example + * ```ts + * import { d1, kvCache } from "@emdash-cms/cloudflare"; + * + * emdash({ + * database: d1({ binding: "DB" }), + * objectCache: kvCache({ binding: "CACHE" }), + * }) + * ``` + * + * ```jsonc + * // wrangler.jsonc + * { "kv_namespaces": [{ "binding": "CACHE", "id": "" }] } + * ``` + */ +export function kvCache(config: KVCacheConfig): ObjectCacheDescriptor { + return { + entrypoint: "@emdash-cms/cloudflare/cache/kv", + config: { + binding: config.binding, + ...(config.defaultTtl !== undefined ? { defaultTtl: config.defaultTtl } : {}), + ...(config.revalidate !== undefined ? { revalidate: config.revalidate } : {}), + ...(config.keyPrefix !== undefined ? { keyPrefix: config.keyPrefix } : {}), + }, + }; +} + // Re-export media providers (config-time) export { cloudflareImages, type CloudflareImagesConfig } from "./media/images.js"; export { cloudflareStream, type CloudflareStreamConfig } from "./media/stream.js"; diff --git a/packages/cloudflare/tsdown.config.ts b/packages/cloudflare/tsdown.config.ts index 2e524c7b44..b3fa82536d 100644 --- a/packages/cloudflare/tsdown.config.ts +++ b/packages/cloudflare/tsdown.config.ts @@ -14,9 +14,11 @@ export default defineConfig({ // Media provider runtimes "src/media/images-runtime.ts", "src/media/stream-runtime.ts", - // Cache provider + // Cache provider (full-page response cache) "src/cache/runtime.ts", "src/cache/config.ts", + // Object cache backend (KV) + "src/cache/kv.ts", ], format: ["esm"], dts: true, diff --git a/packages/core/package.json b/packages/core/package.json index e611888a95..5be2cca133 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -98,6 +98,10 @@ "types": "./dist/storage/s3.d.mts", "default": "./dist/storage/s3.mjs" }, + "./object-cache/memory": { + "types": "./dist/object-cache/memory.d.mts", + "default": "./dist/object-cache/memory.mjs" + }, "./media": { "types": "./dist/media/index.d.mts", "default": "./dist/media/index.mjs" diff --git a/packages/core/src/astro/index.ts b/packages/core/src/astro/index.ts index af9168f4f9..df3464776c 100644 --- a/packages/core/src/astro/index.ts +++ b/packages/core/src/astro/index.ts @@ -21,6 +21,12 @@ export type { export { local, s3 } from "./storage/index.js"; export type { StorageDescriptor, LocalStorageConfig, S3StorageConfig } from "./storage/index.js"; +// Object cache adapters (for integration config) +// Note: For Cloudflare KV, use `kvCache()` from `@emdash-cms/cloudflare` +export { memoryCache } from "./object-cache/adapters.js"; +export type { MemoryCacheOptions } from "./object-cache/adapters.js"; +export type { ObjectCacheDescriptor, ObjectCacheRuntimeConfig } from "../object-cache/types.js"; + // Integration (build-time only - the emdash() function uses Node.js APIs) export { default } from "./integration/index.js"; export { getStoredConfig } from "./integration/runtime.js"; diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 61d504a9c2..024a5e9089 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -10,6 +10,7 @@ import type { AuthDescriptor, AuthProviderDescriptor } from "../../auth/types.js"; import type { DatabaseDescriptor } from "../../db/adapters.js"; import type { MediaProviderDescriptor } from "../../media/types.js"; +import type { ObjectCacheDescriptor } from "../../object-cache/types.js"; import type { ResolvedPlugin } from "../../plugins/types.js"; import type { ExperimentalConfig } from "../../registry/types.js"; import type { StorageDescriptor } from "../storage/types.js"; @@ -151,6 +152,41 @@ export interface EmDashConfig { * Storage configuration (for media) */ storage?: StorageDescriptor; + + /** + * Optional distributed object cache for query results. + * + * Off by default. When configured, content and chrome (settings, menus, + * taxonomies) reads are cached in a fast key/value store and served without + * touching the database on repeat requests across isolates. This offloads + * read pressure from D1/SQLite, which is especially valuable on Cloudflare + * where D1 has far lower request capacity than KV. + * + * Use a backend adapter: + * - `memoryCache()` from `emdash/astro` — in-isolate (Node / local dev) + * - `kvCache({ binding: "CACHE" })` from `@emdash-cms/cloudflare` — KV + * + * Authenticated, preview, and visual-edit requests always bypass the cache, + * so editors see live content immediately. Anonymous visitors may see + * content up to `revalidate` ms stale after an edit (default 1s). + * + * Scheduled content becomes visible at query time (no write event fires when + * its publish time passes), so a cached list/entry won't surface a newly-due + * scheduled item until the next write to that collection or until the + * entry's TTL lapses (`defaultTtl`, default 1h). Sites that rely on precise + * scheduled publishing should lower `defaultTtl` accordingly. + * + * @example + * ```ts + * import { kvCache } from "@emdash-cms/cloudflare"; + * + * emdash({ + * database: d1({ binding: "DB" }), + * objectCache: kvCache({ binding: "CACHE" }), + * }) + * ``` + */ + objectCache?: ObjectCacheDescriptor; /** * Trusted plugins to load (run in main isolate) * diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index f130c2309c..aa4f1d09c1 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -33,6 +33,9 @@ export const RESOLVED_VIRTUAL_DIALECT_ID = "\0" + VIRTUAL_DIALECT_ID; export const VIRTUAL_STORAGE_ID = "virtual:emdash/storage"; export const RESOLVED_VIRTUAL_STORAGE_ID = "\0" + VIRTUAL_STORAGE_ID; +export const VIRTUAL_OBJECT_CACHE_ID = "virtual:emdash/object-cache"; +export const RESOLVED_VIRTUAL_OBJECT_CACHE_ID = "\0" + VIRTUAL_OBJECT_CACHE_ID; + export const VIRTUAL_ADMIN_REGISTRY_ID = "virtual:emdash/admin-registry"; export const RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID = "\0" + VIRTUAL_ADMIN_REGISTRY_ID; @@ -125,6 +128,31 @@ export const createStorage = _createStorage; `; } +/** + * Generates the object-cache virtual module. + * + * Statically imports the configured object-cache backend's `createObjectCache` + * factory and embeds its serializable config. When no object cache is + * configured, exports `undefined` so the runtime read-through layer becomes a + * transparent passthrough (cache off by default). + */ +export function generateObjectCacheModule( + entrypoint?: string, + config?: Record, +): string { + if (!entrypoint) { + return [ + `export const createObjectCache = undefined;`, + `export const objectCacheConfig = undefined;`, + ].join("\n"); + } + return ` +import { createObjectCache as _createObjectCache } from "${entrypoint}"; +export const createObjectCache = _createObjectCache; +export const objectCacheConfig = ${JSON.stringify(config ?? {})}; +`; +} + /** * Generates the auth virtual module. * Statically imports the configured auth provider. diff --git a/packages/core/src/astro/integration/vite-config.ts b/packages/core/src/astro/integration/vite-config.ts index 982877adab..0490f1740e 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -22,6 +22,8 @@ import { RESOLVED_VIRTUAL_DIALECT_ID, VIRTUAL_STORAGE_ID, RESOLVED_VIRTUAL_STORAGE_ID, + VIRTUAL_OBJECT_CACHE_ID, + RESOLVED_VIRTUAL_OBJECT_CACHE_ID, VIRTUAL_ADMIN_REGISTRY_ID, RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID, VIRTUAL_PLUGINS_ID, @@ -47,6 +49,7 @@ import { generateConfigModule, generateDialectModule, generateStorageModule, + generateObjectCacheModule, generateAuthModule, generateAuthProvidersModule, generatePluginsModule, @@ -173,6 +176,9 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin { if (id === VIRTUAL_STORAGE_ID) { return RESOLVED_VIRTUAL_STORAGE_ID; } + if (id === VIRTUAL_OBJECT_CACHE_ID) { + return RESOLVED_VIRTUAL_OBJECT_CACHE_ID; + } if (id === VIRTUAL_ADMIN_REGISTRY_ID) { return RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID; } @@ -221,6 +227,14 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin { if (id === RESOLVED_VIRTUAL_STORAGE_ID) { return generateStorageModule(resolvedConfig.storage?.entrypoint); } + // Generate the object-cache module — statically imports the + // configured backend factory, or exports undefined (cache off). + if (id === RESOLVED_VIRTUAL_OBJECT_CACHE_ID) { + return generateObjectCacheModule( + resolvedConfig.objectCache?.entrypoint, + resolvedConfig.objectCache?.config, + ); + } // Generate plugins module that imports and instantiates all plugins if (id === RESOLVED_VIRTUAL_PLUGINS_ID) { return generatePluginsModule(pluginDescriptors); diff --git a/packages/core/src/astro/object-cache/adapters.ts b/packages/core/src/astro/object-cache/adapters.ts new file mode 100644 index 0000000000..fa30f6f1e9 --- /dev/null +++ b/packages/core/src/astro/object-cache/adapters.ts @@ -0,0 +1,50 @@ +/** + * Object-cache adapter functions (config time). + * + * These run in `astro.config.mjs` and return serializable + * {@link ObjectCacheDescriptor}s. The backend is instantiated at runtime by + * loading the descriptor's `entrypoint`. + * + * For Cloudflare KV, use `kvCache()` from `@emdash-cms/cloudflare`. + * + * @example + * ```ts + * // astro.config.mjs (Node / local) + * import emdash, { memoryCache } from "emdash/astro"; + * + * export default defineConfig({ + * integrations: [emdash({ objectCache: memoryCache() })], + * }); + * ``` + */ + +import type { ObjectCacheDescriptor, ObjectCacheRuntimeConfig } from "../../object-cache/types.js"; + +/** Options for {@link memoryCache}. */ +export interface MemoryCacheOptions extends ObjectCacheRuntimeConfig { + /** + * Soft cap on the number of cached keys per isolate before FIFO eviction. + * @default 1000 + */ + maxEntries?: number; +} + +/** + * In-isolate memory object cache. + * + * Caches query results across requests within a single isolate/process. On + * Node (one long-lived process) this is a genuine cross-request cache; on + * multi-isolate platforms (Cloudflare) prefer `kvCache()` so the cache is + * shared. Useful for local development regardless of target. + * + * @example + * ```ts + * emdash({ objectCache: memoryCache({ defaultTtl: 600 }) }) + * ``` + */ +export function memoryCache(options: MemoryCacheOptions = {}): ObjectCacheDescriptor { + return { + entrypoint: "emdash/object-cache/memory", + config: { ...options }, + }; +} diff --git a/packages/core/src/database/repositories/byline.ts b/packages/core/src/database/repositories/byline.ts index 37b782de14..9b4bc8ff15 100644 --- a/packages/core/src/database/repositories/byline.ts +++ b/packages/core/src/database/repositories/byline.ts @@ -2,6 +2,10 @@ import { sql, type Kysely, type Selectable } from "kysely"; import { ulid } from "ulidx"; import { getBylineFieldDefs } from "../../bylines/field-defs-cache.js"; +import { + invalidateBylineObjectCache, + invalidateCollectionCache, +} from "../../object-cache/index.js"; import { clearRequestCacheEntry, peekRequestCache, @@ -773,6 +777,7 @@ export class BylineRepository { if (touchedGroupShared) { clearRequestCacheEntry(`byline-field-group-values:${translationGroup}`); } + invalidateBylineObjectCache(); const byline = await this.findById(id); if (!byline) { @@ -820,6 +825,7 @@ export class BylineRepository { if (touchedGroupShared) { clearRequestCacheEntry(`byline-field-group-values:${group}`); } + invalidateBylineObjectCache(); return await this.findById(id); } @@ -908,6 +914,7 @@ export class BylineRepository { } }); + invalidateBylineObjectCache(); return true; } @@ -1271,6 +1278,9 @@ export class BylineRepository { SET primary_byline_id = ${firstByline} WHERE id = ${targetContentId} `.execute(this.db); + + // Byline credits are folded into the target entry's cached payload. + invalidateCollectionCache(collection); } /** @@ -1365,6 +1375,9 @@ export class BylineRepository { WHERE id = ${contentId} `.execute(this.db); + // Byline credits are folded into this entry's cached payload. + invalidateCollectionCache(collectionSlug); + return await this.getContentBylines(collectionSlug, contentId); } } diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index abb69af826..243fe7d839 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -1,6 +1,7 @@ import { sql, type Kysely } from "kysely"; import { ulid } from "ulidx"; +import { invalidateCollectionCache } from "../../object-cache/index.js"; import { slugify } from "../../utils/slugify.js"; import type { Database } from "../types.js"; import { validateIdentifier } from "../validate.js"; @@ -189,6 +190,8 @@ export class ContentRepository { VALUES (${sql.join(valuePlaceholders, sql`, `)}) `.execute(this.db); + invalidateCollectionCache(type); + // Fetch and return the created item const item = await this.findById(type, id); if (!item) { @@ -602,6 +605,8 @@ export class ContentRepository { .where("deleted_at" as never, "is", null) .execute(); + invalidateCollectionCache(type); + const updated = await this.findById(type, id); if (!updated) { throw new Error("Content not found"); @@ -624,7 +629,9 @@ export class ContentRepository { AND deleted_at IS NULL `.execute(this.db); - return (result.numAffectedRows ?? 0n) > 0n; + const changed = (result.numAffectedRows ?? 0n) > 0n; + if (changed) invalidateCollectionCache(type); + return changed; } /** @@ -640,7 +647,9 @@ export class ContentRepository { AND deleted_at IS NOT NULL `.execute(this.db); - return (result.numAffectedRows ?? 0n) > 0n; + const changed = (result.numAffectedRows ?? 0n) > 0n; + if (changed) invalidateCollectionCache(type); + return changed; } /** @@ -665,7 +674,9 @@ export class ContentRepository { AND deleted_at IS NOT NULL `.execute(this.db); - return (result.numAffectedRows ?? 0n) > 0n; + const changed = (result.numAffectedRows ?? 0n) > 0n; + if (changed) invalidateCollectionCache(type); + return changed; } /** @@ -882,6 +893,8 @@ export class ContentRepository { AND deleted_at IS NULL `.execute(this.db); + invalidateCollectionCache(type); + const updated = await this.findById(type, id); if (!updated) { throw new Error("Content not found"); @@ -919,6 +932,8 @@ export class ContentRepository { AND deleted_at IS NULL `.execute(this.db); + invalidateCollectionCache(type); + const updated = await this.findById(type, id); if (!updated) { throw new Error("Content not found"); @@ -1047,6 +1062,8 @@ export class ContentRepository { `.execute(this.db); } + invalidateCollectionCache(type); + const updated = await this.findById(type, id); if (!updated) { throw new Error("Content not found"); @@ -1099,6 +1116,8 @@ export class ContentRepository { AND deleted_at IS NULL `.execute(this.db); + invalidateCollectionCache(type); + const updated = await this.findById(type, id); if (!updated) { throw new Error("Content not found"); @@ -1144,6 +1163,8 @@ export class ContentRepository { WHERE id = ${id} AND deleted_at IS NULL `.execute(this.db); + + invalidateCollectionCache(type); } /** @@ -1174,6 +1195,8 @@ export class ContentRepository { AND deleted_at IS NULL `.execute(this.db); + invalidateCollectionCache(type); + const updated = await this.findById(type, id); if (!updated) { throw new Error("Content not found"); diff --git a/packages/core/src/database/repositories/menu.ts b/packages/core/src/database/repositories/menu.ts index 3dc2de463c..df53af7a9e 100644 --- a/packages/core/src/database/repositories/menu.ts +++ b/packages/core/src/database/repositories/menu.ts @@ -16,6 +16,7 @@ import type { Kysely, Selectable } from "kysely"; import { ulid } from "ulidx"; +import { invalidateMenuObjectCache } from "../../object-cache/index.js"; import { withTransaction } from "../transaction.js"; import type { Database, MenuItemTable, MenuTable } from "../types.js"; @@ -369,6 +370,8 @@ export class MenuRepository { } }); + invalidateMenuObjectCache(); + const created = await this.findById(id); if (!created) throw new Error("Failed to create menu"); return created; @@ -383,6 +386,7 @@ export class MenuRepository { if (Object.keys(values).length > 0) { await this.db.updateTable("_emdash_menus").set(values).where("id", "=", id).execute(); + invalidateMenuObjectCache(); } return (await this.findById(id))!; @@ -404,6 +408,7 @@ export class MenuRepository { await trx.deleteFrom("_emdash_menu_items").where("menu_id", "=", id).execute(); await trx.deleteFrom("_emdash_menus").where("id", "=", id).execute(); }); + invalidateMenuObjectCache(); return true; } @@ -488,6 +493,8 @@ export class MenuRepository { }) .execute(); + invalidateMenuObjectCache(); + const row = await this.db .selectFrom("_emdash_menu_items") .selectAll() @@ -529,6 +536,7 @@ export class MenuRepository { .set(values) .where("id", "=", itemId) .execute(); + invalidateMenuObjectCache(); } const row = await this.db @@ -546,7 +554,9 @@ export class MenuRepository { .where("id", "=", itemId) .where("menu_id", "=", menuId) .execute(); - return result[0]?.numDeletedRows !== 0n; + const deleted = result[0]?.numDeletedRows !== 0n; + if (deleted) invalidateMenuObjectCache(); + return deleted; } /** @@ -614,6 +624,7 @@ export class MenuRepository { .execute(); }); + invalidateMenuObjectCache(); return { itemCount: items.length }; } @@ -622,6 +633,7 @@ export class MenuRepository { * malicious payload cannot move foreign items into this menu's siblings. */ async reorderItems(menuId: string, items: ReorderItem[]): Promise { + invalidateMenuObjectCache(); return withTransaction(this.db, async (trx) => { for (const item of items) { await trx diff --git a/packages/core/src/database/repositories/seo.ts b/packages/core/src/database/repositories/seo.ts index 79a2f8dcf8..fe3bec9c72 100644 --- a/packages/core/src/database/repositories/seo.ts +++ b/packages/core/src/database/repositories/seo.ts @@ -1,5 +1,6 @@ import { sql, type Kysely } from "kysely"; +import { invalidateCollectionCache } from "../../object-cache/index.js"; import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; import type { Database } from "../types.js"; import type { ContentSeo, ContentSeoInput } from "./types.js"; @@ -153,6 +154,7 @@ export class SeoRepository { updated_at = ${now} `.execute(this.db); + invalidateCollectionCache(collection); return this.get(collection, contentId); } @@ -165,6 +167,7 @@ export class SeoRepository { .where("collection", "=", collection) .where("content_id", "=", contentId) .execute(); + invalidateCollectionCache(collection); } /** diff --git a/packages/core/src/database/repositories/taxonomy.ts b/packages/core/src/database/repositories/taxonomy.ts index c3d2b53f82..3acc974adc 100644 --- a/packages/core/src/database/repositories/taxonomy.ts +++ b/packages/core/src/database/repositories/taxonomy.ts @@ -1,6 +1,7 @@ import type { Kysely, Selectable } from "kysely"; import { ulid } from "ulidx"; +import { invalidateTaxonomyObjectCache } from "../../object-cache/index.js"; import type { Database, TaxonomyTable, ContentTaxonomyTable } from "../types.js"; export interface Taxonomy { @@ -88,6 +89,8 @@ export class TaxonomyRepository { }) .execute(); + invalidateTaxonomyObjectCache(); + const taxonomy = await this.findById(id); if (!taxonomy) throw new Error("Failed to create taxonomy"); return taxonomy; @@ -187,6 +190,7 @@ export class TaxonomyRepository { if (Object.keys(updates).length > 0) { await this.db.updateTable("taxonomies").set(updates).where("id", "=", id).execute(); + invalidateTaxonomyObjectCache(); } return this.findById(id); @@ -214,6 +218,7 @@ export class TaxonomyRepository { } const result = await this.db.deleteFrom("taxonomies").where("id", "=", id).executeTakeFirst(); + invalidateTaxonomyObjectCache(); return (result.numDeletedRows ?? 0n) > 0n; } @@ -233,6 +238,7 @@ export class TaxonomyRepository { .values(row) .onConflict((oc) => oc.doNothing()) .execute(); + invalidateTaxonomyObjectCache(); } async detachFromEntry(collection: string, entryId: string, taxonomyId: string): Promise { @@ -245,6 +251,7 @@ export class TaxonomyRepository { .where("entry_id", "=", entryId) .where("taxonomy_id", "=", group) .execute(); + invalidateTaxonomyObjectCache(); } /** @@ -324,6 +331,8 @@ export class TaxonomyRepository { .onConflict((oc) => oc.doNothing()) .execute(); } + + if (toRemove.length > 0 || toAdd.length > 0) invalidateTaxonomyObjectCache(); } async clearEntryTerms(collection: string, entryId: string): Promise { @@ -332,7 +341,9 @@ export class TaxonomyRepository { .where("collection", "=", collection) .where("entry_id", "=", entryId) .executeTakeFirst(); - return Number(result.numDeletedRows ?? 0); + const removed = Number(result.numDeletedRows ?? 0); + if (removed > 0) invalidateTaxonomyObjectCache(); + return removed; } /** @@ -364,6 +375,7 @@ export class TaxonomyRepository { ) .onConflict((oc) => oc.doNothing()) .execute(); + invalidateTaxonomyObjectCache(); } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a642612265..015450b6bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -191,6 +191,26 @@ export type { } from "./storage/types.js"; export { EmDashStorageError } from "./storage/types.js"; +// Object cache (distributed read-through query cache) +export { + cachedQuery, + invalidateObjectCache, + invalidateCollectionCache, + invalidateTaxonomyObjectCache, + invalidateBylineObjectCache, + invalidateMenuObjectCache, + contentNamespace, + contentNamespaces, + CacheNamespace, +} from "./object-cache/index.js"; +export type { CachedQueryOptions } from "./object-cache/index.js"; +export type { + ObjectCacheBackend, + ObjectCacheDescriptor, + ObjectCacheRuntimeConfig, + CreateObjectCacheBackendFn, +} from "./object-cache/types.js"; + // Plugin system export { definePlugin, diff --git a/packages/core/src/menus/index.ts b/packages/core/src/menus/index.ts index bb1202eefb..c8cdde180d 100644 --- a/packages/core/src/menus/index.ts +++ b/packages/core/src/menus/index.ts @@ -15,6 +15,7 @@ import type { Database } from "../database/types.js"; import { validateIdentifier } from "../database/validate.js"; import { resolveLocale, resolveLocaleChain } from "../i18n/resolve.js"; import { getDb } from "../loader.js"; +import { cachedQuery, CacheNamespace } from "../object-cache/index.js"; import { requestCached } from "../request-cache.js"; import { sanitizeHref } from "../utils/url.js"; import type { Menu, MenuItem, MenuItemRow } from "./types.js"; @@ -36,10 +37,16 @@ export interface MenuQueryOptions { */ export function getMenu(name: string, options: MenuQueryOptions = {}): Promise { const locale = resolveLocale(options.locale); - return requestCached(`menu:${name}:${locale ?? "*"}`, async () => { - const db = await getDb(); - return getMenuWithDb(name, db, { locale }); - }); + return requestCached(`menu:${name}:${locale ?? "*"}`, () => + cachedQuery({ + namespace: CacheNamespace.MENUS, + key: `${name}:${locale ?? "*"}`, + load: async () => { + const db = await getDb(); + return getMenuWithDb(name, db, { locale }); + }, + }), + ); } /** diff --git a/packages/core/src/object-cache/codec.ts b/packages/core/src/object-cache/codec.ts new file mode 100644 index 0000000000..dc33467f50 --- /dev/null +++ b/packages/core/src/object-cache/codec.ts @@ -0,0 +1,71 @@ +/** + * Object-cache serialization codec. + * + * Cached values are JSON, with one extension: `Date` instances are preserved + * across the round-trip. EmDash content entries carry `Date` objects for the + * system timestamp columns (`createdAt`, `updatedAt`, `publishedAt`, + * `scheduledAt`) and on `cacheHint.lastModified`; plain `JSON.stringify` would + * silently flatten those to ISO strings, so a value read from cache would no + * longer be `=== instanceof Date` and downstream `value instanceof Date` + * branches (cursor encoding, scheduled-visibility checks) would diverge from a + * fresh database read. + * + * Functions and symbol-keyed properties are NOT preserved — callers that cache + * values carrying either (e.g. content entries with their `.edit` proxy and + * the non-enumerable `CURSOR_RAW_VALUES` symbol) must reduce to a serializable + * snapshot before caching and rebuild the non-serializable parts on read. See + * `query.ts` content snapshot helpers. + */ + +/** Tag used to mark a serialized `Date`. Deliberately unlikely to collide. */ +const DATE_TAG = "$$emdashDate"; + +interface TaggedDate { + [DATE_TAG]: string; +} + +function isTaggedDate(value: unknown): value is TaggedDate { + return ( + typeof value === "object" && + value !== null && + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- narrowing a JSON-parsed value to read the date tag + typeof (value as Record)[DATE_TAG] === "string" + ); +} + +/** + * Serialize a value to a cache string, preserving `Date` instances. + * + * Uses the JSON replacer's `this` binding to inspect the *original* property + * value: `JSON.stringify` invokes `Date.prototype.toJSON` before the replacer + * sees it, so by the time `value` arrives it is already an ISO string. Reading + * `this[key]` recovers the live `Date` so we can tag it. + */ +export function encode(value: unknown): string { + return JSON.stringify(value, function (this: Record, key, val) { + const original = this[key]; + if (original instanceof Date) { + return { [DATE_TAG]: original.toISOString() } satisfies TaggedDate; + } + return val; + }); +} + +/** + * Parse a cache string produced by {@link encode}, rehydrating tagged `Date`s. + * + * Returns `undefined` if the input is not valid JSON (treated as a cache miss + * by the read-through layer rather than throwing). + */ +export function decode(raw: string): unknown { + try { + return JSON.parse(raw, (_key, value) => { + if (isTaggedDate(value)) { + return new Date(value[DATE_TAG]); + } + return value; + }); + } catch { + return undefined; + } +} diff --git a/packages/core/src/object-cache/index.ts b/packages/core/src/object-cache/index.ts new file mode 100644 index 0000000000..b9658f3c38 --- /dev/null +++ b/packages/core/src/object-cache/index.ts @@ -0,0 +1,399 @@ +/** + * Object cache — distributed read-through query cache. + * + * Layering (per query): + * + * requestCached → in-request dedupe (per render, WeakMap on ALS context) + * cachedQuery → THIS layer: distributed L2 (KV / memory), epoch-keyed + * database → source of truth + * + * Optional and off by default: when no `objectCache` descriptor is configured, + * `virtual:emdash/object-cache` exports `createObjectCache = undefined`, + * {@link getBackend} resolves to `null`, and {@link cachedQuery} is a + * transparent passthrough to its `load` function. Configure with + * `memoryCache()` (Node) or `kvCache()` from `@emdash-cms/cloudflare`. + * + * Invalidation is epoch-based: each cache key embeds a per-namespace epoch + * ("last changed" marker) read from the backend. A write calls + * {@link invalidateObjectCache}, which stamps the namespace epoch to + * `Date.now()`; every previously-stored key for that namespace is instantly + * orphaned and reclaimed by its TTL. This is O(1) and needs no key + * enumeration (KV has no prefix delete). + * + * The singleton backend/config and the per-isolate epoch cache live on + * `globalThis` behind `Symbol.for` keys so Vite SSR chunk duplication can't + * fork them (same pattern as `request-context.ts`). + */ + +import { after } from "../after.js"; +import { getRequestContext } from "../request-context.js"; +import { decode, encode } from "./codec.js"; +import type { + CreateObjectCacheBackendFn, + ObjectCacheBackend, + ObjectCacheRuntimeConfig, +} from "./types.js"; + +const DEFAULT_KEY_PREFIX = "em"; +const DEFAULT_TTL_SECONDS = 3600; +const DEFAULT_REVALIDATE_MS = 1000; + +interface BackendHolder { + /** Whether the virtual module has been loaded and the backend resolved. */ + initialized: boolean; + /** Resolved backend, or `null` when no object cache is configured. */ + backend: ObjectCacheBackend | null; + /** In-flight initialization promise (dedupes concurrent first calls). */ + initPromise: Promise | null; + config: Required> & { + defaultTtl: number; + revalidate: number; + }; +} + +interface EpochEntry { + value: number; + /** `Date.now()` at which this epoch was read from the backend. */ + at: number; + /** In-flight read, so concurrent callers share one backend round-trip. */ + promise?: Promise; +} + +const BACKEND_KEY = Symbol.for("emdash:object-cache:backend"); +const EPOCH_KEY = Symbol.for("emdash:object-cache:epochs"); +const PENDING_KEY = Symbol.for("emdash:object-cache:pending-bumps"); +const g = globalThis as Record; + +const holder: BackendHolder = + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern (see request-context.ts) + (g[BACKEND_KEY] as BackendHolder | undefined) ?? + (() => { + const h: BackendHolder = { + initialized: false, + backend: null, + initPromise: null, + config: { + keyPrefix: DEFAULT_KEY_PREFIX, + defaultTtl: DEFAULT_TTL_SECONDS, + revalidate: DEFAULT_REVALIDATE_MS, + }, + }; + g[BACKEND_KEY] = h; + return h; + })(); + +const epochCache: Map = + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern (see request-context.ts) + (g[EPOCH_KEY] as Map | undefined) ?? + (() => { + const m = new Map(); + g[EPOCH_KEY] = m; + return m; + })(); + +/** Namespaces with a backend epoch write already scheduled this tick. */ +const pendingBumps: Set = + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern (see request-context.ts) + (g[PENDING_KEY] as Set | undefined) ?? + (() => { + const s = new Set(); + g[PENDING_KEY] = s; + return s; + })(); + +/** + * Resolve (once per isolate) the configured object-cache backend. + * + * Loads `virtual:emdash/object-cache`, which exports `createObjectCache` + * (`undefined` when no cache is configured) and the serialized + * `objectCacheConfig`. Returns `null` when the cache is disabled. + */ +async function getBackend(): Promise { + if (holder.initialized) return holder.backend; + if (holder.initPromise) return holder.initPromise; + + holder.initPromise = (async () => { + try { + const mod: { + createObjectCache?: CreateObjectCacheBackendFn; + objectCacheConfig?: ObjectCacheRuntimeConfig; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - virtual module + } = await import("virtual:emdash/object-cache"); + + const config = mod.objectCacheConfig ?? {}; + holder.config = { + keyPrefix: + typeof config.keyPrefix === "string" && config.keyPrefix.length > 0 + ? config.keyPrefix + : DEFAULT_KEY_PREFIX, + defaultTtl: + typeof config.defaultTtl === "number" && config.defaultTtl > 0 + ? config.defaultTtl + : DEFAULT_TTL_SECONDS, + revalidate: + typeof config.revalidate === "number" && config.revalidate >= 0 + ? config.revalidate + : DEFAULT_REVALIDATE_MS, + }; + + holder.backend = + typeof mod.createObjectCache === "function" ? mod.createObjectCache(config) : null; + } catch (error) { + // Importing the virtual module fails outside an Astro/Vite context + // (e.g. unit tests, CLI). Treat as "no cache configured". + if (process.env["EMDASH_DEBUG_OBJECT_CACHE"]) { + console.warn("[object-cache] backend unavailable:", error); + } + holder.backend = null; + } + holder.initialized = true; + holder.initPromise = null; + return holder.backend; + })(); + + return holder.initPromise; +} + +/** + * Test-only override of the backend, bypassing the virtual module. + * + * Lets unit tests inject an in-memory backend (and optional config) without a + * full Astro/Vite build. Pass `null` to simulate "no cache configured". + * + * @internal + */ +export function __setObjectCacheBackendForTests( + backend: ObjectCacheBackend | null, + config?: Partial, +): void { + holder.initialized = true; + holder.initPromise = null; + holder.backend = backend; + holder.config = { ...holder.config, ...config }; + epochCache.clear(); +} + +/** Build the backend key for a namespace's epoch anchor. */ +function epochKey(namespace: string): string { + return `${holder.config.keyPrefix}:epoch:${namespace}`; +} + +/** Build the backend key for a cached value within one or more namespaces. */ +function valueKey(namespaces: readonly string[], epochs: readonly number[], key: string): string { + const sig = namespaces.map((ns, i) => `${ns}@${epochs[i]}`).join(","); + return `${holder.config.keyPrefix}:${sig}:${key}`; +} + +/** + * Requests that must always read live data and never populate the cache: + * visual edit mode, preview tokens, and isolated databases (playground / DO + * preview, whose schema and content diverge from the configured site). + */ +function shouldBypass(): boolean { + const ctx = getRequestContext(); + if (!ctx) return false; + return ctx.editMode === true || ctx.preview !== undefined || ctx.dbIsIsolated === true; +} + +/** + * Read the current epoch for `namespace`, reusing an isolate-cached value for + * up to `revalidate` ms. A missing epoch (never bumped) is treated as `0`. + * + * Backend errors are non-fatal: we fall back to the last known epoch (or `0`), + * so a flaky cache degrades to "serve whatever's keyed" rather than throwing. + */ +async function getEpoch(namespace: string, backend: ObjectCacheBackend): Promise { + const now = Date.now(); + const cached = epochCache.get(namespace); + if (cached && now - cached.at < holder.config.revalidate) { + return cached.value; + } + if (cached?.promise) return cached.promise; + + const promise = (async () => { + try { + const raw = await backend.get(epochKey(namespace)); + const parsed = raw === null ? 0 : Number(raw); + const value = Number.isFinite(parsed) ? parsed : 0; + epochCache.set(namespace, { value, at: Date.now() }); + return value; + } catch { + const fallback = cached?.value ?? 0; + epochCache.set(namespace, { value: fallback, at: Date.now() }); + return fallback; + } + })(); + + epochCache.set(namespace, { value: cached?.value ?? 0, at: cached?.at ?? 0, promise }); + return promise; +} + +/** Options for {@link cachedQuery}. */ +export interface CachedQueryOptions { + /** + * Invalidation namespace(s). A single string for self-contained data + * (`settings`, `menus`), or several when the cached value depends on data + * owned by other namespaces — e.g. a content entry hydrates bylines and + * taxonomy terms, so it caches under + * `[content:posts, "bylines", "taxonomies"]` and is invalidated when *any* + * of them is bumped. Every namespace's epoch is folded into the key. + */ + namespace: string | readonly string[]; + /** Stable, fully-qualifying cache key *within* the namespace. */ + key: string; + /** Loader run on a miss (or when caching is disabled/bypassed). */ + load: () => Promise; + /** TTL override in seconds. Falls back to the configured `defaultTtl`. */ + ttl?: number; + /** + * Predicate gating whether a freshly-loaded value is stored. Defaults to + * always-cache. Use it to skip caching error/empty sentinels. + */ + cacheable?: (value: T) => boolean; +} + +/** + * Distributed read-through cache around `load`. + * + * `T` must be the value as it should be *stored* — i.e. JSON-serializable with + * the codec's `Date` support, carrying no functions or symbol-keyed props. + * Callers caching richer objects (content entries) reduce to a serializable + * snapshot here and rebuild on the way out; see `query.ts`. + * + * On a miss or when the cache is disabled/bypassed, this is equivalent to + * `await load()`. Backend errors never propagate: a failing `get` is a miss, a + * failing `set` is dropped. + */ +export async function cachedQuery(options: CachedQueryOptions): Promise { + const backend = await getBackend(); + if (!backend || shouldBypass()) { + return options.load(); + } + + const namespaces = + typeof options.namespace === "string" ? [options.namespace] : options.namespace; + const epochs = await Promise.all(namespaces.map((ns) => getEpoch(ns, backend))); + const fullKey = valueKey(namespaces, epochs, options.key); + + try { + const raw = await backend.get(fullKey); + if (raw !== null) { + const decoded = decode(raw); + if (decoded !== undefined) { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- key namespacing guarantees the stored value matches T + return decoded as T; + } + } + } catch { + // Treat backend read errors as a miss. + } + + const value = await options.load(); + + const cacheable = options.cacheable ? options.cacheable(value) : true; + if (cacheable) { + const raw = encode(value); + const ttl = options.ttl ?? holder.config.defaultTtl; + // Defer the write so it never adds to TTFB. + after(async () => { + try { + await backend.set(fullKey, raw, ttl); + } catch (error) { + if (process.env["EMDASH_DEBUG_OBJECT_CACHE"]) { + console.warn("[object-cache] set failed:", error); + } + } + }); + } + + return value; +} + +/** + * Invalidate every cached value in `namespace` by bumping its epoch. + * + * Sync and non-blocking: the local epoch is stamped immediately (so the + * writing isolate is instantly consistent) and the backend write is deferred + * via `after`. Other isolates pick up the new epoch within their `revalidate` + * window. No-ops when the cache is disabled. + */ +export function invalidateObjectCache(namespace: string): void { + const stamp = Date.now(); + // Optimistic local bump: keep this isolate consistent without a round-trip. + epochCache.set(namespace, { value: stamp, at: stamp }); + + // Coalesce repeated bumps of the same namespace within a tick (e.g. a bulk + // publish loop) into a single backend write that persists the latest epoch. + if (pendingBumps.has(namespace)) return; + pendingBumps.add(namespace); + after(async () => { + pendingBumps.delete(namespace); + try { + const backend = await getBackend(); + if (!backend) return; + const latest = epochCache.get(namespace)?.value ?? stamp; + // Epoch anchors are persistent (no TTL) — they must outlive the + // value keys they invalidate. + await backend.set(epochKey(namespace), String(latest)); + } catch (error) { + console.error("[object-cache] epoch bump failed for", namespace, error); + } + }); +} + +/** + * Fixed namespaces for data shared across collections. Content reads fold the + * `BYLINES` and `TAXONOMIES` epochs into their keys (via {@link cachedQuery}) + * because entries hydrate byline and taxonomy-term data — so renaming an + * author or a category correctly invalidates every cached entry that displays + * it, without tracking which collections reference it. + */ +export const CacheNamespace = { + SETTINGS: "settings", + MENUS: "menus", + TAXONOMIES: "taxonomies", + BYLINES: "bylines", +} as const; + +/** Namespace for a content collection's cached queries. */ +export function contentNamespace(collection: string): string { + return `content:${collection}`; +} + +/** + * Namespaces a content read depends on: the collection itself plus the shared + * byline/taxonomy data folded into each entry. + */ +export function contentNamespaces(collection: string): readonly string[] { + return [contentNamespace(collection), CacheNamespace.BYLINES, CacheNamespace.TAXONOMIES]; +} + +/** + * Invalidate all cached reads (list + entry) for a content collection. + * Call from every write path that mutates rows in `ec_`. + */ +export function invalidateCollectionCache(collection: string): void { + invalidateObjectCache(contentNamespace(collection)); +} + +/** Invalidate cached taxonomy definitions/terms and all content that hydrates them. */ +export function invalidateTaxonomyObjectCache(): void { + invalidateObjectCache(CacheNamespace.TAXONOMIES); +} + +/** Invalidate cached bylines and all content that hydrates them. */ +export function invalidateBylineObjectCache(): void { + invalidateObjectCache(CacheNamespace.BYLINES); +} + +/** Invalidate cached navigation menus. */ +export function invalidateMenuObjectCache(): void { + invalidateObjectCache(CacheNamespace.MENUS); +} + +export type { + ObjectCacheBackend, + ObjectCacheDescriptor, + ObjectCacheRuntimeConfig, +} from "./types.js"; diff --git a/packages/core/src/object-cache/memory.ts b/packages/core/src/object-cache/memory.ts new file mode 100644 index 0000000000..eae0a4b3da --- /dev/null +++ b/packages/core/src/object-cache/memory.ts @@ -0,0 +1,91 @@ +/** + * In-isolate memory object-cache backend — RUNTIME ENTRY + * + * The default backend for the Node runtime and a sensible local-dev option. + * Caches across requests within a single isolate/process; it is NOT shared + * across isolates, so on a multi-isolate platform (Cloudflare) you want the KV + * backend instead. Still useful on Node, where one long-lived process serves + * every request. + * + * Wire it up with `memoryCache()` from `emdash`: + * + * ```ts + * import { memoryCache } from "emdash"; + * emdash({ objectCache: memoryCache() }); + * ``` + * + * The store lives on `globalThis` behind a `Symbol.for` key so Vite SSR chunk + * duplication doesn't create two independent caches (same pattern as + * `request-context.ts`). + */ + +import type { CreateObjectCacheBackendFn, ObjectCacheBackend } from "./types.js"; + +interface Entry { + value: string; + /** Absolute expiry in ms (`performance.now()` epoch), or `null` for none. */ + expiresAt: number | null; +} + +interface MemoryStore { + map: Map; + maxEntries: number; +} + +const STORE_KEY = Symbol.for("emdash:object-cache:memory"); +const g = globalThis as Record; + +function getStore(maxEntries: number): MemoryStore { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern (see request-context.ts) + const existing = g[STORE_KEY] as MemoryStore | undefined; + if (existing) { + // First descriptor wins for sizing; later calls reuse the same map. + return existing; + } + const store: MemoryStore = { map: new Map(), maxEntries }; + g[STORE_KEY] = store; + return store; +} + +/** + * Create the in-isolate memory backend. + * + * Config keys (all optional): + * - `maxEntries` — soft cap on stored keys; oldest insertions are evicted + * first when exceeded (FIFO, cheap and good enough for a backstop). Default + * 1000. + */ +export const createObjectCache: CreateObjectCacheBackendFn = (config): ObjectCacheBackend => { + const maxEntries = typeof config.maxEntries === "number" ? config.maxEntries : 1000; + const store = getStore(maxEntries); + + return { + get(key: string): Promise { + const entry = store.map.get(key); + if (!entry) return Promise.resolve(null); + if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) { + store.map.delete(key); + return Promise.resolve(null); + } + return Promise.resolve(entry.value); + }, + set(key: string, value: string, ttlSeconds?: number): Promise { + // Refresh insertion order so recently-written keys survive eviction. + store.map.delete(key); + store.map.set(key, { + value, + expiresAt: ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null, + }); + while (store.map.size > store.maxEntries) { + const oldest = store.map.keys().next().value; + if (oldest === undefined) break; + store.map.delete(oldest); + } + return Promise.resolve(); + }, + delete(key: string): Promise { + store.map.delete(key); + return Promise.resolve(); + }, + }; +}; diff --git a/packages/core/src/object-cache/types.ts b/packages/core/src/object-cache/types.ts new file mode 100644 index 0000000000..990c52b553 --- /dev/null +++ b/packages/core/src/object-cache/types.ts @@ -0,0 +1,106 @@ +/** + * Object cache types + * + * The object cache is an optional, distributed read-through cache that sits + * *beneath* the per-request cache (`requestCached`) and *above* the database. + * Query results (content entries, settings, menus, taxonomies) are stored in a + * fast key/value store (Cloudflare KV, or an in-isolate memory store for Node) + * so repeat reads across requests and isolates skip the database entirely. + * + * Backends only ever deal in strings — serialization (including `Date` + * preservation) is handled by the core read-through layer in `./codec.ts`, so + * every backend behaves identically. + */ + +/** + * A pluggable object-cache backend. + * + * Implementations must be safe to call concurrently and should never throw on + * cache misses — `get` returns `null` for a miss. A backend that throws (e.g. + * a transient KV error) degrades gracefully: the read-through layer treats a + * thrown `get` as a miss and a thrown `set` as a no-op, so the database + * remains the source of truth. + */ +export interface ObjectCacheBackend { + /** Return the stored string for `key`, or `null` on a miss. */ + get(key: string): Promise; + /** + * Store `value` under `key`. + * + * @param ttlSeconds Optional time-to-live in seconds. Backends that don't + * support TTLs may ignore it, but distributed backends (KV) should honor + * it so orphaned, epoch-busted keys are eventually reclaimed. + */ + set(key: string, value: string, ttlSeconds?: number): Promise; + /** Remove `key`. Idempotent — deleting a missing key is not an error. */ + delete(key: string): Promise; +} + +/** + * Serializable descriptor for an object-cache backend. + * + * Mirrors {@link import("../storage/types.js").StorageDescriptor}: `entrypoint` + * is a module specifier resolved at build time that exports a + * {@link CreateObjectCacheBackendFn} named `createObjectCache`; `config` is the + * plain, serializable runtime config passed to it. + */ +export interface ObjectCacheDescriptor { + /** Module path exporting a `createObjectCache` function. */ + entrypoint: string; + /** Serializable config passed to `createObjectCache` at runtime. */ + config: ObjectCacheRuntimeConfig; +} + +/** + * Runtime config shared by every backend, plus backend-specific keys. + * + * The read-through layer reads `defaultTtl`, `revalidate`, and `keyPrefix`; + * individual backends read their own keys (e.g. the KV backend reads + * `binding`). + */ +export interface ObjectCacheRuntimeConfig { + /** + * Default time-to-live for cached entries, in seconds. + * + * Epoch-based invalidation orphans stale keys instantly (see + * `./index.ts`), so the TTL is a backstop that reclaims orphaned keys and + * bounds staleness for anything not covered by an explicit epoch bump. + * + * @default 3600 (1 hour) + */ + defaultTtl?: number; + /** + * How long (milliseconds) an isolate may reuse a cached namespace epoch + * before re-reading it from the backend. + * + * This is the cross-isolate staleness window: after a write bumps a + * namespace's epoch, other isolates keep serving the previous epoch's keys + * until their cached epoch expires. Authenticated/preview/edit requests + * bypass the cache entirely, so editors always see fresh content; this + * window only affects anonymous visitors. + * + * Set to `0` to re-read the epoch on every query (strongest freshness, more + * backend reads). + * + * @default 1000 + */ + revalidate?: number; + /** + * Prefix applied to every cache key. Lets multiple EmDash sites share one + * KV namespace without colliding. + * + * @default "em" + */ + keyPrefix?: string; + /** Backend-specific keys (e.g. the KV binding name). */ + [key: string]: unknown; +} + +/** + * Factory signature exported as `createObjectCache` from a backend entrypoint. + * + * Each backend accesses its own resources directly: the KV backend imports + * bindings from `cloudflare:workers`; the memory backend uses an in-isolate + * map. + */ +export type CreateObjectCacheBackendFn = (config: ObjectCacheRuntimeConfig) => ObjectCacheBackend; diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index 0f6e1fb323..b1812b8148 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -27,6 +27,7 @@ import { encodeCursor } from "./database/repositories/types.js"; import { getFallbackChain, getI18nConfig, isI18nEnabled } from "./i18n/config.js"; import { CURSOR_RAW_VALUES, type WhereRange, type WhereValue } from "./loader.js"; +import { cachedQuery, contentNamespaces } from "./object-cache/index.js"; import { requestCached } from "./request-cache.js"; import { getRequestContext } from "./request-context.js"; import { isMissingTableError } from "./utils/db-errors.js"; @@ -310,13 +311,61 @@ export async function getEmDashCollection - getEmDashCollectionUncached(type, bucketed.fetchFilter), + loadCollectionCached(type, bucketed.fetchFilter), ); return bucketed.requestedLimit === undefined ? cached : sliceCollectionResult(cached, bucketed.requestedLimit, filter?.orderBy); } +/** Shape of a cached collection snapshot (entries reduced to JSON-safe form). */ +interface CachedCollectionValue { + entries: unknown[]; + nextCursor?: string; + cacheHint: CacheHint; +} + +/** + * Distributed (L2) read-through around {@link getEmDashCollectionUncached}. + * + * Caches a JSON-safe snapshot keyed by collection + filter + effective locale, + * folding the shared `bylines`/`taxonomies` epochs into the key so renaming an + * author or term invalidates affected lists. Errors are never cached. + */ +async function loadCollectionCached>( + type: T, + filter?: CollectionFilter, +): Promise> { + const snapshot = await cachedQuery>({ + namespace: contentNamespaces(type), + key: `collection:${collectionCacheKey(type, filter)}|loc=${effectiveLocaleKey(filter)}`, + load: async () => { + const result = await getEmDashCollectionUncached(type, filter); + if (result.error) { + return { ok: false, error: result.error, cacheHint: result.cacheHint }; + } + return { + ok: true, + value: { + entries: result.entries.map(entrySnapshot), + nextCursor: result.nextCursor, + cacheHint: result.cacheHint, + }, + }; + }, + cacheable: (snap) => snap.ok, + }); + + if (!snapshot.ok) { + return { entries: [], error: snapshot.error, cacheHint: snapshot.cacheHint }; + } + return { + entries: snapshot.value.entries.map((entry) => reviveEntry(entry)), + nextCursor: snapshot.value.nextCursor, + cacheHint: snapshot.value.cacheHint, + }; +} + /** * Threshold for limit bucketing. Page templates routinely render small * "recent posts" widgets at limits 3-8; rounding those up to a single @@ -481,6 +530,66 @@ function stableOrder(value: Record): Record { return ordered; } +// ── Object-cache (L2) serialization for content reads ─────────────────────── +// +// Content entries can't be stored verbatim: each carries a non-serializable +// `edit` proxy (a function) and a non-enumerable `CURSOR_RAW_VALUES` symbol on +// `data` (raw date strings used to reproduce the loader's pagination cursor). +// We reduce each entry to a JSON-safe snapshot before caching — copying the +// cursor-raw values into an enumerable field and dropping `edit` — then rebuild +// the symbol and re-attach a no-op `edit` on the way out. The object cache's +// codec preserves `Date` instances, so timestamps survive the round-trip. +// +// L2 is only consulted for anonymous, non-preview, non-edit requests (see +// `shouldBypass` in object-cache), where `edit` is always the no-op variant — +// so dropping and recreating it is lossless. + +/** Enumerable field carrying the {@link CURSOR_RAW_VALUES} payload in snapshots. */ +const CURSOR_RAW_FIELD = "__emdashCursorRaw"; + +/** Result wrapper distinguishing a cached error from a cacheable success. */ +type ContentSnapshot = + | { ok: true; value: S } + | { ok: false; error?: Error; cacheHint: CacheHint }; + +function entrySnapshot(entry: ContentEntry): Record { + const data = entryData(entry); + const rawCursor = Reflect.get(data, CURSOR_RAW_VALUES); + // Drop the `edit` function; copy enumerable data + the cursor-raw values. + const { edit: _edit, ...rest } = entry as ContentEntry & { edit?: unknown }; + return { + ...rest, + data: { ...data, [CURSOR_RAW_FIELD]: rawCursor ?? {} }, + }; +} + +function reviveEntry(raw: unknown): ContentEntry { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot shape produced by entrySnapshot + const entry = raw as Record; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot `data` is always a record + const data: Record = { ...(entry.data as Record) }; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- snapshot field written by entrySnapshot + const rawCursor = (data[CURSOR_RAW_FIELD] as Record | undefined) ?? {}; + delete data[CURSOR_RAW_FIELD]; + Object.defineProperty(data, CURSOR_RAW_VALUES, { + value: rawCursor, + enumerable: false, + configurable: false, + writable: false, + }); + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- rebuilt to the ContentEntry shape with a no-op edit proxy + return { ...entry, data, edit: createNoop() } as ContentEntry; +} + +/** Resolve the effective locale used by content reads, for the L2 cache key. */ +function effectiveLocaleKey(filter?: { locale?: string }): string { + const ctx = getRequestContext(); + const i18nConfig = getI18nConfig(); + return ( + filter?.locale ?? ctx?.locale ?? (isI18nEnabled() ? i18nConfig!.defaultLocale : undefined) ?? "" + ); +} + async function getEmDashCollectionUncached>( type: T, filter?: CollectionFilter, @@ -692,27 +801,71 @@ export async function getEmDashEntry 0 ? locale : undefined; + // Normal mode: try each locale in the fallback chain, only return published + // content. The full resolution (fallback chain + visibility + byline/term + // hydration) is wrapped in the distributed L2 cache, keyed by the requested + // locale. Preview/edit requests took the `serveDrafts` branch above and + // never reach here; the object cache additionally bypasses them. + const resolveNormal = async (): Promise> => { + for (let i = 0; i < localeChain.length; i++) { + const locale = localeChain[i]; + const fallbackLocale = i > 0 ? locale : undefined; - const { entry, error, cacheHint } = await getLiveEntry(COLLECTION_NAME, { type, id, locale }); - if (error) { - return { entry: null, error, isPreview: false, cacheHint: {} }; - } + const { entry, error, cacheHint } = await getLiveEntry(COLLECTION_NAME, { type, id, locale }); + if (error) { + return { entry: null, error, isPreview: false, cacheHint: {} }; + } - if (entry && isVisible(entry)) { - return successResult(wrapEntry(entry), { - isPreview: false, - fallbackLocale, - cacheHint: cacheHint ?? {}, - }); + if (entry && isVisible(entry)) { + return successResult(wrapEntry(entry), { + isPreview: false, + fallbackLocale, + cacheHint: cacheHint ?? {}, + }); + } + // Entry not found or not visible in this locale — try next } - // Entry not found or not visible in this locale — try next + return { entry: null, isPreview: false, cacheHint: {} }; + }; + + const snapshot = await cachedQuery>({ + namespace: contentNamespaces(type), + key: `entry:${id}|loc=${requestedLocale ?? ""}`, + load: async () => { + const result = await resolveNormal(); + if (result.error) { + return { ok: false, error: result.error, cacheHint: result.cacheHint }; + } + return { + ok: true, + value: { + entry: result.entry ? entrySnapshot(result.entry) : null, + isPreview: result.isPreview, + fallbackLocale: result.fallbackLocale, + cacheHint: result.cacheHint, + }, + }; + }, + cacheable: (snap) => snap.ok, + }); + + if (!snapshot.ok) { + return { entry: null, error: snapshot.error, isPreview: false, cacheHint: snapshot.cacheHint }; } + return { + entry: snapshot.value.entry ? reviveEntry(snapshot.value.entry) : null, + isPreview: snapshot.value.isPreview, + fallbackLocale: snapshot.value.fallbackLocale, + cacheHint: snapshot.value.cacheHint, + }; +} - return { entry: null, isPreview: false, cacheHint: {} }; +/** Shape of a cached single-entry snapshot. */ +interface CachedEntryValue { + entry: Record | null; + isPreview: boolean; + fallbackLocale?: string; + cacheHint: CacheHint; } /** diff --git a/packages/core/src/settings/index.ts b/packages/core/src/settings/index.ts index 92df2816b7..f9e854e54c 100644 --- a/packages/core/src/settings/index.ts +++ b/packages/core/src/settings/index.ts @@ -11,7 +11,11 @@ import { MediaRepository } from "../database/repositories/media.js"; import { OptionsRepository } from "../database/repositories/options.js"; import type { Database } from "../database/types.js"; import { getDb } from "../loader.js"; +import { cachedQuery, invalidateObjectCache } from "../object-cache/index.js"; import { peekRequestCache, requestCached } from "../request-cache.js"; + +/** Object-cache namespace for site settings. */ +const SETTINGS_CACHE_NAMESPACE = "settings"; import type { Storage } from "../storage/types.js"; import type { SiteSettings, SiteSettingKey, MediaReference, SeoSettings } from "./types.js"; @@ -63,6 +67,8 @@ export function invalidateSiteSettingsCache(): void { holder.version++; holder.cached = null; holder.cachedVersion = -1; + // Cross-isolate invalidation for the optional distributed object cache. + invalidateObjectCache(SETTINGS_CACHE_NAMESPACE); } /** @@ -215,10 +221,14 @@ export function getSiteSettings(): Promise> { if (holder.cached && holder.cachedVersion === versionAtCall) { return holder.cached; } - const fetchPromise = (async () => { - const db = await getDb(); - return getSiteSettingsWithDb(db); - })().catch((error) => { + const fetchPromise = cachedQuery({ + namespace: SETTINGS_CACHE_NAMESPACE, + key: "all", + load: async () => { + const db = await getDb(); + return getSiteSettingsWithDb(db); + }, + }).catch((error) => { if (holder.cached === fetchPromise) { holder.cached = null; holder.cachedVersion = -1; diff --git a/packages/core/src/taxonomies/index.ts b/packages/core/src/taxonomies/index.ts index 0571045f39..e6ac7350b3 100644 --- a/packages/core/src/taxonomies/index.ts +++ b/packages/core/src/taxonomies/index.ts @@ -13,6 +13,11 @@ import { resolveLocale, resolveLocaleChain } from "../i18n/resolve.js"; import { getDb } from "../loader.js"; +import { + cachedQuery, + CacheNamespace, + invalidateTaxonomyObjectCache, +} from "../object-cache/index.js"; import { peekRequestCache, requestCached, setRequestCacheEntry } from "../request-cache.js"; import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js"; import { isMissingTableError } from "../utils/db-errors.js"; @@ -23,10 +28,12 @@ export interface TaxonomyQueryOptions { } /** - * No-op — kept for API compatibility. + * Invalidate cached taxonomy data in the distributed object cache (and any + * content that hydrates taxonomy terms). The legacy in-isolate term cache was + * removed, so this used to be a no-op; it now drives object-cache invalidation. */ export function invalidateTermCache(): void { - // Intentionally empty. + invalidateTaxonomyObjectCache(); } /** @@ -36,13 +43,19 @@ export function invalidateTermCache(): void { */ export async function getTaxonomyDefs(options: TaxonomyQueryOptions = {}): Promise { const locale = resolveLocale(options.locale); - return requestCached(`taxonomy-defs:${locale ?? "*"}`, async () => { - const db = await getDb(); - let query = db.selectFrom("_emdash_taxonomy_defs").selectAll(); - if (locale !== undefined) query = query.where("locale", "=", locale); - const rows = await query.execute(); - return rows.map(rowToTaxonomyDef); - }); + return requestCached(`taxonomy-defs:${locale ?? "*"}`, () => + cachedQuery({ + namespace: CacheNamespace.TAXONOMIES, + key: `defs:${locale ?? "*"}`, + load: async () => { + const db = await getDb(); + let query = db.selectFrom("_emdash_taxonomy_defs").selectAll(); + if (locale !== undefined) query = query.where("locale", "=", locale); + const rows = await query.execute(); + return rows.map(rowToTaxonomyDef); + }, + }), + ); } /** @@ -106,54 +119,66 @@ export async function getTaxonomyTerms( options: TaxonomyQueryOptions = {}, ): Promise { const locale = resolveLocale(options.locale); - return requestCached(`taxonomy-terms:${taxonomyName}:${locale ?? "*"}`, async () => { - const db = await getDb(); + return requestCached(`taxonomy-terms:${taxonomyName}:${locale ?? "*"}`, () => + cachedQuery({ + namespace: CacheNamespace.TAXONOMIES, + key: `terms:${taxonomyName}:${locale ?? "*"}`, + load: () => loadTaxonomyTerms(taxonomyName, locale, options), + }), + ); +} - const def = await getTaxonomyDef(taxonomyName, options); - if (!def) return []; +async function loadTaxonomyTerms( + taxonomyName: string, + locale: string | undefined, + options: TaxonomyQueryOptions, +): Promise { + const db = await getDb(); - let termsQuery = db - .selectFrom("taxonomies") - .selectAll() - .where("name", "=", taxonomyName) - .orderBy("label", "asc"); - if (locale !== undefined) termsQuery = termsQuery.where("locale", "=", locale); - const rows = await termsQuery.execute(); - - // Counts are keyed by translation_group (what the pivot stores). - const countsResult = await db - .selectFrom("content_taxonomies") - .select(["taxonomy_id"]) - .select((eb) => eb.fn.count("entry_id").as("count")) - .groupBy("taxonomy_id") - .execute(); - const counts = new Map(); - for (const row of countsResult) counts.set(row.taxonomy_id, row.count); - - const flatTerms: TaxonomyTermRow[] = rows.map((row) => ({ - id: row.id, - name: row.name, - slug: row.slug, - label: row.label, - parent_id: row.parent_id, - data: row.data, - locale: row.locale, - translation_group: row.translation_group, - })); - - if (def.hierarchical) return buildTree(flatTerms, counts); - - return flatTerms.map((term) => ({ - id: term.id, - name: term.name, - slug: term.slug, - label: term.label, - children: [], - count: counts.get(term.translation_group ?? term.id) ?? 0, - locale: term.locale, - translationGroup: term.translation_group, - })); - }); + const def = await getTaxonomyDef(taxonomyName, options); + if (!def) return []; + + let termsQuery = db + .selectFrom("taxonomies") + .selectAll() + .where("name", "=", taxonomyName) + .orderBy("label", "asc"); + if (locale !== undefined) termsQuery = termsQuery.where("locale", "=", locale); + const rows = await termsQuery.execute(); + + // Counts are keyed by translation_group (what the pivot stores). + const countsResult = await db + .selectFrom("content_taxonomies") + .select(["taxonomy_id"]) + .select((eb) => eb.fn.count("entry_id").as("count")) + .groupBy("taxonomy_id") + .execute(); + const counts = new Map(); + for (const row of countsResult) counts.set(row.taxonomy_id, row.count); + + const flatTerms: TaxonomyTermRow[] = rows.map((row) => ({ + id: row.id, + name: row.name, + slug: row.slug, + label: row.label, + parent_id: row.parent_id, + data: row.data, + locale: row.locale, + translation_group: row.translation_group, + })); + + if (def.hierarchical) return buildTree(flatTerms, counts); + + return flatTerms.map((term) => ({ + id: term.id, + name: term.name, + slug: term.slug, + label: term.label, + children: [], + count: counts.get(term.translation_group ?? term.id) ?? 0, + locale: term.locale, + translationGroup: term.translation_group, + })); } /** diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts index fc1429668b..c052ad9df8 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -65,6 +65,17 @@ declare module "virtual:emdash/storage" { export const createStorage: ((config: Record) => Storage) | undefined; } +declare module "virtual:emdash/object-cache" { + import type { + CreateObjectCacheBackendFn, + ObjectCacheRuntimeConfig, + } from "./object-cache/types.js"; + + // Can be undefined if no object cache is configured. + export const createObjectCache: CreateObjectCacheBackendFn | undefined; + export const objectCacheConfig: ObjectCacheRuntimeConfig | undefined; +} + declare module "virtual:emdash/auth" { import type { AuthResult } from "./auth/types.js"; diff --git a/packages/core/tests/unit/object-cache-content.test.ts b/packages/core/tests/unit/object-cache-content.test.ts new file mode 100644 index 0000000000..bbc8132bbc --- /dev/null +++ b/packages/core/tests/unit/object-cache-content.test.ts @@ -0,0 +1,130 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true }); +vi.mock("astro:content", () => ({ + getLiveCollection: vi.fn(), + getLiveEntry: vi.fn(), +})); + +import { getLiveCollection } from "astro:content"; + +import type { Database } from "../../src/database/types.js"; +import { CURSOR_RAW_VALUES } from "../../src/loader.js"; +import { + __setObjectCacheBackendForTests, + invalidateCollectionCache, + type ObjectCacheBackend, +} from "../../src/object-cache/index.js"; +import { getEmDashCollection } from "../../src/query.js"; +import { runWithContext } from "../../src/request-context.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../utils/test-db.js"; + +function spyBackend(): ObjectCacheBackend { + const store = new Map(); + return { + get: (key) => Promise.resolve(store.get(key) ?? null), + set: (key, value) => { + store.set(key, value); + return Promise.resolve(); + }, + delete: (key) => { + store.delete(key); + return Promise.resolve(); + }, + }; +} + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +describe("object cache: content read-through", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + __setObjectCacheBackendForTests(spyBackend(), { revalidate: 1000, defaultTtl: 3600 }); + vi.mocked(getLiveCollection).mockReset(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + __setObjectCacheBackendForTests(null); + }); + + function mockEntries() { + const data: Record = { + id: "db-1", + title: "Hello", + status: "published", + createdAt: new Date("2025-01-01T00:00:00.000Z"), + }; + // The loader attaches raw date strings under a non-enumerable symbol; + // emulate it so we can assert the snapshot preserves it. + Object.defineProperty(data, CURSOR_RAW_VALUES, { + value: { created_at: "2025-01-01T00:00:00Z" }, + enumerable: false, + configurable: false, + writable: false, + }); + return [{ id: "hello", slug: "hello", status: "published", data, cacheHint: {} }]; + } + + it("serves a second identical query from cache without re-querying the loader", async () => { + vi.mocked(getLiveCollection).mockResolvedValue({ + entries: mockEntries(), + error: undefined, + cacheHint: {}, + // eslint-disable-next-line typescript/no-explicit-any -- mocked loader result + } as any); + + await runWithContext({ editMode: false, db }, () => getEmDashCollection("post")); + await flush(); + const second = await runWithContext({ editMode: false, db }, () => getEmDashCollection("post")); + + expect(getLiveCollection).toHaveBeenCalledTimes(1); + expect(second.entries).toHaveLength(1); + // Date survives the cache round-trip. + const createdAt = (second.entries[0]!.data as { createdAt: unknown }).createdAt; + expect(createdAt).toBeInstanceOf(Date); + // The cursor-raw symbol is rebuilt on the cached entry. + expect(Reflect.get(second.entries[0]!.data as object, CURSOR_RAW_VALUES)).toEqual({ + created_at: "2025-01-01T00:00:00Z", + }); + }); + + it("reloads after the collection is invalidated by a write", async () => { + vi.mocked(getLiveCollection).mockResolvedValue({ + entries: mockEntries(), + error: undefined, + cacheHint: {}, + // eslint-disable-next-line typescript/no-explicit-any -- mocked loader result + } as any); + + await runWithContext({ editMode: false, db }, () => getEmDashCollection("post")); + await flush(); + await runWithContext({ editMode: false, db }, () => getEmDashCollection("post")); + expect(getLiveCollection).toHaveBeenCalledTimes(1); + + invalidateCollectionCache("post"); + await flush(); + + await runWithContext({ editMode: false, db }, () => getEmDashCollection("post")); + expect(getLiveCollection).toHaveBeenCalledTimes(2); + }); + + it("bypasses the cache in edit mode", async () => { + vi.mocked(getLiveCollection).mockResolvedValue({ + entries: mockEntries(), + error: undefined, + cacheHint: {}, + // eslint-disable-next-line typescript/no-explicit-any -- mocked loader result + } as any); + + await runWithContext({ editMode: true, db }, () => getEmDashCollection("post")); + await flush(); + await runWithContext({ editMode: true, db }, () => getEmDashCollection("post")); + expect(getLiveCollection).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/tests/unit/object-cache.test.ts b/packages/core/tests/unit/object-cache.test.ts new file mode 100644 index 0000000000..5c9de91245 --- /dev/null +++ b/packages/core/tests/unit/object-cache.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true }); + +import { decode, encode } from "../../src/object-cache/codec.js"; +import { + __setObjectCacheBackendForTests, + cachedQuery, + invalidateObjectCache, + type ObjectCacheBackend, +} from "../../src/object-cache/index.js"; +import { createObjectCache as createMemoryCache } from "../../src/object-cache/memory.js"; +import { runWithContext } from "../../src/request-context.js"; + +/** Flush the microtask + macrotask queue so deferred `after()` writes land. */ +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +/** A simple in-memory backend with call spies, isolated per test. */ +function spyBackend(): ObjectCacheBackend & { store: Map } { + const store = new Map(); + return { + store, + get: vi.fn((key: string) => Promise.resolve(store.get(key) ?? null)), + set: vi.fn((key: string, value: string) => { + store.set(key, value); + return Promise.resolve(); + }), + delete: vi.fn((key: string) => { + store.delete(key); + return Promise.resolve(); + }), + }; +} + +describe("object-cache codec", () => { + it("round-trips primitives, arrays, and nested objects", () => { + const value = { a: 1, b: "two", c: [3, 4], d: { e: true, f: null } }; + expect(decode(encode(value))).toEqual(value); + }); + + it("preserves Date instances", () => { + const value = { createdAt: new Date("2024-01-02T03:04:05.678Z"), nested: { d: new Date(0) } }; + const decoded = decode(encode(value)) as typeof value; + expect(decoded.createdAt).toBeInstanceOf(Date); + expect(decoded.createdAt.toISOString()).toBe("2024-01-02T03:04:05.678Z"); + expect(decoded.nested.d).toBeInstanceOf(Date); + }); + + it("drops functions and symbol-keyed properties (not JSON-representable)", () => { + const sym = Symbol("hidden"); + const value: Record = { keep: 1, fn: () => 42 }; + Object.defineProperty(value, sym, { value: "x", enumerable: false }); + const decoded = decode(encode(value)) as Record; + expect(decoded).toEqual({ keep: 1 }); + }); + + it("returns undefined for malformed input (treated as a miss)", () => { + expect(decode("not json{")).toBeUndefined(); + }); +}); + +describe("memory backend", () => { + it("stores and retrieves values", async () => { + const cache = createMemoryCache({ maxEntries: 10 }); + await cache.set("k", "v"); + expect(await cache.get("k")).toBe("v"); + }); + + it("returns null after delete", async () => { + const cache = createMemoryCache({}); + await cache.set("k", "v"); + await cache.delete("k"); + expect(await cache.get("k")).toBeNull(); + }); + + it("expires entries past their TTL", async () => { + const cache = createMemoryCache({}); + await cache.set("k", "v", 1); + expect(await cache.get("k")).toBe("v"); + vi.spyOn(Date, "now").mockReturnValue(Date.now() + 2000); + expect(await cache.get("k")).toBeNull(); + vi.restoreAllMocks(); + }); +}); + +describe("cachedQuery", () => { + beforeEach(() => { + __setObjectCacheBackendForTests(spyBackend(), { revalidate: 1000, defaultTtl: 3600 }); + }); + afterEach(() => { + __setObjectCacheBackendForTests(null); + }); + + it("passes through to load when no backend is configured", async () => { + __setObjectCacheBackendForTests(null); + const load = vi.fn(() => Promise.resolve({ n: 1 })); + const result = await cachedQuery({ namespace: "t", key: "k", load }); + expect(result).toEqual({ n: 1 }); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("serves the second call from cache without calling load", async () => { + const load = vi.fn(() => Promise.resolve({ n: Math.random() })); + const first = await cachedQuery({ namespace: "t", key: "k", load }); + await flush(); // let the deferred set land + const second = await cachedQuery({ namespace: "t", key: "k", load }); + expect(second).toEqual(first); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("preserves Date values through the cache round-trip", async () => { + const load = vi.fn(() => Promise.resolve({ when: new Date("2025-06-01T00:00:00.000Z") })); + await cachedQuery({ namespace: "t", key: "d", load }); + await flush(); + const hit = await cachedQuery<{ when: Date }>({ namespace: "t", key: "d", load }); + expect(hit.when).toBeInstanceOf(Date); + expect(hit.when.toISOString()).toBe("2025-06-01T00:00:00.000Z"); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("does not cache values rejected by `cacheable`", async () => { + const load = vi.fn(() => Promise.resolve({ ok: false })); + await cachedQuery({ namespace: "t", key: "e", load, cacheable: (v) => v.ok }); + await flush(); + await cachedQuery({ namespace: "t", key: "e", load, cacheable: (v) => v.ok }); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("bypasses the cache in edit mode", async () => { + const load = vi.fn(() => Promise.resolve({ n: 1 })); + await runWithContext({ editMode: true }, async () => { + await cachedQuery({ namespace: "t", key: "k", load }); + await cachedQuery({ namespace: "t", key: "k", load }); + }); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("bypasses the cache for preview requests", async () => { + const load = vi.fn(() => Promise.resolve({ n: 1 })); + await runWithContext( + { editMode: false, preview: { collection: "posts", id: "1" } }, + async () => { + await cachedQuery({ namespace: "t", key: "k", load }); + await cachedQuery({ namespace: "t", key: "k", load }); + }, + ); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("reloads after the namespace is invalidated", async () => { + const load = vi.fn(() => Promise.resolve({ n: Math.random() })); + await cachedQuery({ namespace: "posts", key: "k", load }); + await flush(); + await cachedQuery({ namespace: "posts", key: "k", load }); + expect(load).toHaveBeenCalledTimes(1); + + invalidateObjectCache("posts"); + await flush(); + + await cachedQuery({ namespace: "posts", key: "k", load }); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("invalidates when any namespace in a multi-namespace key is bumped", async () => { + const load = vi.fn(() => Promise.resolve({ n: Math.random() })); + const ns = ["content:posts", "bylines", "taxonomies"]; + await cachedQuery({ namespace: ns, key: "k", load }); + await flush(); + await cachedQuery({ namespace: ns, key: "k", load }); + expect(load).toHaveBeenCalledTimes(1); + + // Bump only the shared bylines namespace. + invalidateObjectCache("bylines"); + await flush(); + + await cachedQuery({ namespace: ns, key: "k", load }); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("treats a backend read error as a miss without throwing", async () => { + const backend = spyBackend(); + backend.get = vi.fn(() => Promise.reject(new Error("kv down"))); + __setObjectCacheBackendForTests(backend, { revalidate: 1000, defaultTtl: 3600 }); + const load = vi.fn(() => Promise.resolve({ n: 1 })); + const result = await cachedQuery({ namespace: "t", key: "k", load }); + expect(result).toEqual({ n: 1 }); + expect(load).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index fe677f4063..3491105df5 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -91,6 +91,8 @@ export default defineConfig({ // Storage adapters (runtime - loaded via virtual:emdash/storage) "src/storage/local.ts", "src/storage/s3.ts", + // Object-cache memory backend (runtime - loaded via virtual:emdash/object-cache) + "src/object-cache/memory.ts", // Media providers "src/media/index.ts", "src/media/local-runtime.ts", From 30944dc2678be7a36d582540631f76abad172ade Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Fri, 5 Jun 2026 19:02:47 -0700 Subject: [PATCH 02/15] fix(core): bound object-cache backend reads with a timeout A KV read that stalls without resolving or rejecting (cold cross-region read, or one queued behind the Workers connection limit) could hang the isolate: getEpoch cached the never-settling promise and every later cached read on that namespace reused it, poisoning the isolate until it recycled. - Race every backend read against a timeout (default 2000ms, configurable via the `timeout` option on kvCache/objectCache, 0 disables). A timed-out read degrades to a cache miss; the database stays the source of truth. - Apply the timeout in the KV backend (get/set/delete) and in the core read path (getEpoch + cachedQuery value read), so any backend that stalls self-heals once the bounded read settles. - Also switch the cache debug-log gate from process.env to import.meta.env.DEV (repo convention). Adds regression tests: a never-settling backend resolves via load() instead of hanging, the namespace self-heals afterward, and the KV backend rejects a stalled get/set. --- packages/cloudflare/src/cache/kv.ts | 47 +++++++++++++---- packages/cloudflare/src/index.ts | 7 +++ .../cloudflare/tests/cache/kv-timeout.test.ts | 28 +++++++++++ packages/core/src/object-cache/index.ts | 50 ++++++++++++++++--- packages/core/src/object-cache/types.ts | 15 ++++++ packages/core/tests/unit/object-cache.test.ts | 49 ++++++++++++++++++ 6 files changed, 179 insertions(+), 17 deletions(-) create mode 100644 packages/cloudflare/tests/cache/kv-timeout.test.ts diff --git a/packages/cloudflare/src/cache/kv.ts b/packages/cloudflare/src/cache/kv.ts index ebfcd123d9..af32155ffa 100644 --- a/packages/cloudflare/src/cache/kv.ts +++ b/packages/cloudflare/src/cache/kv.ts @@ -33,6 +33,28 @@ import type { CreateObjectCacheBackendFn, ObjectCacheBackend } from "emdash"; */ const KV_MIN_TTL_SECONDS = 60; +/** + * Default ceiling (ms) for a single KV operation. A KV read can stall without + * ever resolving or rejecting — a cold cross-region read, or one queued behind + * the Workers six-simultaneous-connection limit. Left unbounded, that hangs the + * isolate. Racing against a timeout turns a stall into a rejection, which the + * object-cache read path treats as a benign cache miss. + */ +const DEFAULT_KV_TIMEOUT_MS = 2000; + +/** + * Reject `promise` if it hasn't settled within `ms`. A `ms <= 0` disables the + * timeout. The timer is always cleared so it can't keep the isolate alive. + */ +function withTimeout(promise: Promise, ms: number, label: string): Promise { + if (!(ms > 0)) return promise; + let timer: ReturnType; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`KV ${label} timed out after ${ms}ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + export const createObjectCache: CreateObjectCacheBackendFn = (config): ObjectCacheBackend => { const binding = typeof config.binding === "string" ? config.binding : ""; if (!binding) { @@ -50,22 +72,27 @@ export const createObjectCache: CreateObjectCacheBackendFn = (config): ObjectCac ); } + const timeout = + typeof config.timeout === "number" && config.timeout >= 0 + ? config.timeout + : DEFAULT_KV_TIMEOUT_MS; + return { async get(key: string): Promise { - return (await kv.get(key, "text")) ?? null; + return (await withTimeout(kv.get(key, "text"), timeout, "get")) ?? null; }, async set(key: string, value: string, ttlSeconds?: number): Promise { - if (ttlSeconds && ttlSeconds > 0) { - await kv.put(key, value, { - expirationTtl: Math.max(KV_MIN_TTL_SECONDS, Math.floor(ttlSeconds)), - }); - } else { - // No TTL: persistent key (used for epoch anchors). - await kv.put(key, value); - } + const put = + ttlSeconds && ttlSeconds > 0 + ? kv.put(key, value, { + expirationTtl: Math.max(KV_MIN_TTL_SECONDS, Math.floor(ttlSeconds)), + }) + : // No TTL: persistent key (used for epoch anchors). + kv.put(key, value); + await withTimeout(put, timeout, "put"); }, async delete(key: string): Promise { - await kv.delete(key); + await withTimeout(kv.delete(key), timeout, "delete"); }, }; }; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index bf8ab952b1..8eb39c7636 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -299,6 +299,12 @@ export interface KVCacheConfig { * reuses a cached namespace epoch before re-reading it. Default 1000. */ revalidate?: number; + /** + * Maximum time (ms) for a single KV operation before it's treated as a + * cache miss. Guards against KV reads that stall without settling. Set to + * `0` to disable. Default 2000. + */ + timeout?: number; /** Prefix applied to every cache key (lets multiple sites share a namespace). */ keyPrefix?: string; } @@ -332,6 +338,7 @@ export function kvCache(config: KVCacheConfig): ObjectCacheDescriptor { binding: config.binding, ...(config.defaultTtl !== undefined ? { defaultTtl: config.defaultTtl } : {}), ...(config.revalidate !== undefined ? { revalidate: config.revalidate } : {}), + ...(config.timeout !== undefined ? { timeout: config.timeout } : {}), ...(config.keyPrefix !== undefined ? { keyPrefix: config.keyPrefix } : {}), }, }; diff --git a/packages/cloudflare/tests/cache/kv-timeout.test.ts b/packages/cloudflare/tests/cache/kv-timeout.test.ts new file mode 100644 index 0000000000..69ca0b83d4 --- /dev/null +++ b/packages/cloudflare/tests/cache/kv-timeout.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; + +// The KV backend imports the binding from cloudflare:workers. Provide a fake +// `env` with a KV namespace whose ops never settle — the production hang. +// `vi.hoisted` runs before the hoisted `vi.mock` factory below. +const { stalledKv } = vi.hoisted(() => ({ + stalledKv: { + get: () => new Promise(() => {}), // never resolves or rejects + put: () => new Promise(() => {}), + delete: () => new Promise(() => {}), + }, +})); + +vi.mock("cloudflare:workers", () => ({ env: { CACHE: stalledKv } })); + +import { createObjectCache } from "../../src/cache/kv.js"; + +describe("kvCache backend timeout", () => { + it("rejects a stalled get after the timeout instead of hanging", async () => { + const backend = createObjectCache({ binding: "CACHE", timeout: 20 }); + await expect(backend.get("k")).rejects.toThrow(/timed out/); + }); + + it("rejects a stalled put after the timeout", async () => { + const backend = createObjectCache({ binding: "CACHE", timeout: 20 }); + await expect(backend.set("k", "v")).rejects.toThrow(/timed out/); + }); +}); diff --git a/packages/core/src/object-cache/index.ts b/packages/core/src/object-cache/index.ts index b9658f3c38..6a228ff080 100644 --- a/packages/core/src/object-cache/index.ts +++ b/packages/core/src/object-cache/index.ts @@ -37,6 +37,7 @@ import type { const DEFAULT_KEY_PREFIX = "em"; const DEFAULT_TTL_SECONDS = 3600; const DEFAULT_REVALIDATE_MS = 1000; +const DEFAULT_TIMEOUT_MS = 2000; interface BackendHolder { /** Whether the virtual module has been loaded and the backend resolved. */ @@ -48,9 +49,28 @@ interface BackendHolder { config: Required> & { defaultTtl: number; revalidate: number; + timeout: number; }; } +/** + * Race a backend operation against a timeout so a stalled call (e.g. a KV read + * that never resolves *and* never rejects — a cold cross-region read, or one + * queued behind the Workers simultaneous-connection limit) degrades to a + * rejection instead of hanging the isolate. A rejection is benign: callers + * already treat a failed read as a cache miss / last-known epoch. + */ +function withTimeout(promise: Promise, ms: number, label: string): Promise { + if (!(ms > 0)) return promise; + let timer: ReturnType; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error(`object-cache ${label} timed out after ${ms}ms`)); + }, ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + interface EpochEntry { value: number; /** `Date.now()` at which this epoch was read from the backend. */ @@ -76,6 +96,7 @@ const holder: BackendHolder = keyPrefix: DEFAULT_KEY_PREFIX, defaultTtl: DEFAULT_TTL_SECONDS, revalidate: DEFAULT_REVALIDATE_MS, + timeout: DEFAULT_TIMEOUT_MS, }, }; g[BACKEND_KEY] = h; @@ -135,6 +156,10 @@ async function getBackend(): Promise { typeof config.revalidate === "number" && config.revalidate >= 0 ? config.revalidate : DEFAULT_REVALIDATE_MS, + timeout: + typeof config.timeout === "number" && config.timeout >= 0 + ? config.timeout + : DEFAULT_TIMEOUT_MS, }; holder.backend = @@ -142,7 +167,7 @@ async function getBackend(): Promise { } catch (error) { // Importing the virtual module fails outside an Astro/Vite context // (e.g. unit tests, CLI). Treat as "no cache configured". - if (process.env["EMDASH_DEBUG_OBJECT_CACHE"]) { + if (import.meta.env.DEV) { console.warn("[object-cache] backend unavailable:", error); } holder.backend = null; @@ -200,8 +225,10 @@ function shouldBypass(): boolean { * Read the current epoch for `namespace`, reusing an isolate-cached value for * up to `revalidate` ms. A missing epoch (never bumped) is treated as `0`. * - * Backend errors are non-fatal: we fall back to the last known epoch (or `0`), - * so a flaky cache degrades to "serve whatever's keyed" rather than throwing. + * Backend errors and stalls are non-fatal: the read is bounded by a timeout, + * and on failure we fall back to the last known epoch (or `0`), so a flaky or + * hung cache degrades to "serve whatever's keyed" rather than throwing or + * hanging. */ async function getEpoch(namespace: string, backend: ObjectCacheBackend): Promise { const now = Date.now(); @@ -213,7 +240,11 @@ async function getEpoch(namespace: string, backend: ObjectCacheBackend): Promise const promise = (async () => { try { - const raw = await backend.get(epochKey(namespace)); + const raw = await withTimeout( + backend.get(epochKey(namespace)), + holder.config.timeout, + "epoch read", + ); const parsed = raw === null ? 0 : Number(raw); const value = Number.isFinite(parsed) ? parsed : 0; epochCache.set(namespace, { value, at: Date.now() }); @@ -225,6 +256,11 @@ async function getEpoch(namespace: string, backend: ObjectCacheBackend): Promise } })(); + // Concurrent callers share this in-flight read (dedup). The timeout above + // guarantees `promise` settles — its success/catch handler then replaces + // this entry with a fresh, promise-free one — so a stalled backend can no + // longer pin the namespace to a never-settling promise (the bug that + // poisoned an isolate until it was recycled). epochCache.set(namespace, { value: cached?.value ?? 0, at: cached?.at ?? 0, promise }); return promise; } @@ -277,7 +313,7 @@ export async function cachedQuery(options: CachedQueryOptions): Promise const fullKey = valueKey(namespaces, epochs, options.key); try { - const raw = await backend.get(fullKey); + const raw = await withTimeout(backend.get(fullKey), holder.config.timeout, "read"); if (raw !== null) { const decoded = decode(raw); if (decoded !== undefined) { @@ -286,7 +322,7 @@ export async function cachedQuery(options: CachedQueryOptions): Promise } } } catch { - // Treat backend read errors as a miss. + // Treat backend read errors and timeouts as a miss — fall through to load(). } const value = await options.load(); @@ -300,7 +336,7 @@ export async function cachedQuery(options: CachedQueryOptions): Promise try { await backend.set(fullKey, raw, ttl); } catch (error) { - if (process.env["EMDASH_DEBUG_OBJECT_CACHE"]) { + if (import.meta.env.DEV) { console.warn("[object-cache] set failed:", error); } } diff --git a/packages/core/src/object-cache/types.ts b/packages/core/src/object-cache/types.ts index 990c52b553..e36dd88a0a 100644 --- a/packages/core/src/object-cache/types.ts +++ b/packages/core/src/object-cache/types.ts @@ -92,6 +92,21 @@ export interface ObjectCacheRuntimeConfig { * @default "em" */ keyPrefix?: string; + /** + * Maximum time (milliseconds) to wait for a single backend read before + * treating it as a cache miss and falling back to the database. + * + * Guards against a backend operation that stalls without resolving or + * rejecting (e.g. a cold cross-region KV read, or one queued behind the + * Workers simultaneous-connection limit), which would otherwise hang the + * request. A timed-out read degrades to a miss; the database remains the + * source of truth. + * + * Set to `0` to disable the timeout (not recommended on Cloudflare). + * + * @default 2000 + */ + timeout?: number; /** Backend-specific keys (e.g. the KV binding name). */ [key: string]: unknown; } diff --git a/packages/core/tests/unit/object-cache.test.ts b/packages/core/tests/unit/object-cache.test.ts index 5c9de91245..70dc9644e1 100644 --- a/packages/core/tests/unit/object-cache.test.ts +++ b/packages/core/tests/unit/object-cache.test.ts @@ -188,4 +188,53 @@ describe("cachedQuery", () => { expect(result).toEqual({ n: 1 }); expect(load).toHaveBeenCalledTimes(1); }); + + it("does not hang when a backend read never settles — times out to a miss", async () => { + // A backend.get that never resolves or rejects (the production hang: + // a stalled KV read). With a short timeout the query must still settle. + const backend = spyBackend(); + backend.get = vi.fn(() => new Promise(() => {})); // never settles + __setObjectCacheBackendForTests(backend, { revalidate: 1000, defaultTtl: 3600, timeout: 20 }); + const load = vi.fn(() => Promise.resolve({ n: 1 })); + + const result = await cachedQuery({ namespace: "t", key: "k", load }); + expect(result).toEqual({ n: 1 }); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("self-heals after a stalled read instead of poisoning the namespace", async () => { + // First the backend stalls (epoch + value reads hang); after the timeout + // the namespace must recover and serve from cache on a healthy backend. + const store = new Map(); + let healthy = false; + const backend: ObjectCacheBackend = { + get: (key) => + healthy ? Promise.resolve(store.get(key) ?? null) : new Promise(() => {}), // stalls while unhealthy + set: (key, value) => { + store.set(key, value); + return Promise.resolve(); + }, + delete: (key) => { + store.delete(key); + return Promise.resolve(); + }, + }; + __setObjectCacheBackendForTests(backend, { revalidate: 0, defaultTtl: 3600, timeout: 20 }); + + const load = vi.fn(() => Promise.resolve({ n: 1 })); + + // While stalled: degrades to load() instead of hanging. + await expect(cachedQuery({ namespace: "posts", key: "k", load })).resolves.toEqual({ n: 1 }); + + // Backend recovers; the stuck epoch promise must have settled (timed out) + // and been replaced, so the namespace is usable again. + healthy = true; + await flush(); + await cachedQuery({ namespace: "posts", key: "k", load }); + await flush(); + const calls = load.mock.calls.length; + await cachedQuery({ namespace: "posts", key: "k", load }); + // The second post-recovery call is served from cache (load not re-run). + expect(load.mock.calls.length).toBe(calls); + }); }); From 1cda8d6feec26da70963700c277aebcf86270fa9 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Fri, 5 Jun 2026 19:02:47 -0700 Subject: [PATCH 03/15] docs: document the object cache --- docs/astro.config.mjs | 1 + .../content/docs/deployment/cloudflare.mdx | 16 +++ .../content/docs/deployment/object-cache.mdx | 130 ++++++++++++++++++ .../content/docs/reference/configuration.mdx | 49 +++++++ 4 files changed, 196 insertions(+) create mode 100644 docs/src/content/docs/deployment/object-cache.mdx diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 6b07b9ecab..6e5d1ab00a 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -220,6 +220,7 @@ export default defineConfig({ { label: "Deploy to Node.js", slug: "deployment/nodejs" }, { label: "Database Options", slug: "deployment/database" }, { label: "Storage Options", slug: "deployment/storage" }, + { label: "Object Cache", slug: "deployment/object-cache" }, ], }, { diff --git a/docs/src/content/docs/deployment/cloudflare.mdx b/docs/src/content/docs/deployment/cloudflare.mdx index 63e68aa26b..148f410943 100644 --- a/docs/src/content/docs/deployment/cloudflare.mdx +++ b/docs/src/content/docs/deployment/cloudflare.mdx @@ -96,6 +96,22 @@ You also need to enable read replication on the D1 database itself in the Cloudf See [Database Options — Read Replicas](/deployment/database/#read-replicas) for session modes and how bookmark-based consistency works. +## Object Cache + +To reduce read load on D1, cache content and configuration query results in Cloudflare KV. Reads are served from KV instead of querying the database on every request: + +```js title="astro.config.mjs" +import { d1, r2, kvCache } from "@emdash-cms/cloudflare"; + +emdash({ + database: d1({ binding: "DB" }), + storage: r2({ binding: "MEDIA" }), + objectCache: kvCache({ binding: "CACHE" }), +}), +``` + +See [Object Cache](/deployment/object-cache/) for KV setup, options, and invalidation behavior. + ## Custom Domain Add a custom domain in the Cloudflare dashboard: diff --git a/docs/src/content/docs/deployment/object-cache.mdx b/docs/src/content/docs/deployment/object-cache.mdx new file mode 100644 index 0000000000..b941375a16 --- /dev/null +++ b/docs/src/content/docs/deployment/object-cache.mdx @@ -0,0 +1,130 @@ +--- +title: Object Cache +description: Cache query results in Cloudflare KV or memory to serve reads without querying the database on every request. +--- + +import { Aside, Tabs, TabItem } from "@astrojs/starlight/components"; + +EmDash reads content and site configuration from the database on every request. The object cache stores those query results in a fast key/value store, so repeat requests are served from the cache instead of the database. It reduces read load on the database — useful on Cloudflare, where KV serves far more requests per second than D1. + +The object cache is optional and disabled by default. Enable it by adding an `objectCache` adapter to the `emdash()` integration. + +## Overview + +| Backend | Best for | Shared across isolates | +| ---------- | --------------------------------- | ---------------------- | +| **KV** | Cloudflare Workers | Yes | +| **Memory** | Node.js, local development | No (per process) | + +On Cloudflare, requests are served by many short-lived isolates across regions. KV is shared by all of them, so a value cached by one request is available to the next, anywhere. The memory backend caches within a single process, which suits a long-running Node.js server. + +## Cloudflare KV + +Configure the KV adapter and point it at a KV binding: + +```js title="astro.config.mjs" +import emdash from "emdash/astro"; +import { d1, r2, kvCache } from "@emdash-cms/cloudflare"; + +export default defineConfig({ + integrations: [ + emdash({ + database: d1({ binding: "DB" }), + storage: r2({ binding: "MEDIA" }), + objectCache: kvCache({ binding: "CACHE" }), + }), + ], +}); +``` + +### Setup + +Create a KV namespace and add the binding to your Wrangler configuration. + +```sh +npx wrangler kv namespace create CACHE +``` + +The command prints a namespace `id`. Add it under the binding name used in `kvCache`: + + + + ```jsonc + { + "kv_namespaces": [ + { + "binding": "CACHE", + "id": "" + } + ] + } + ``` + + + ```toml + [[kv_namespaces]] + binding = "CACHE" + id = "" + ``` + + + +### Options + +| Option | Type | Default | Description | +| ------------ | -------- | -------- | --------------------------------------------------------------------------------- | +| `binding` | `string` | — | KV binding name from your Wrangler configuration. Required. | +| `defaultTtl` | `number` | `3600` | Time-to-live for cached entries, in seconds. KV enforces a 60-second minimum. | +| `revalidate` | `number` | `1000` | Cross-isolate staleness window, in milliseconds. See [Freshness](#freshness). | +| `timeout` | `number` | `2000` | Maximum time, in milliseconds, to wait for a KV operation before treating it as a cache miss. Guards against a stalled KV read hanging the request. Set to `0` to disable. | +| `keyPrefix` | `string` | `"em"` | Prefix for every cache key. Set a unique value when several sites share one namespace. | + +## Node.js (memory) + +The memory adapter caches within the server process. It needs no external service: + +```js title="astro.config.mjs" +import emdash, { memoryCache } from "emdash/astro"; +import { sqlite } from "emdash/db"; + +export default defineConfig({ + integrations: [ + emdash({ + database: sqlite({ url: "file:./data.db" }), + objectCache: memoryCache(), + }), + ], +}); +``` + +### Options + +| Option | Type | Default | Description | +| ------------ | -------- | ------- | ------------------------------------------------------ | +| `defaultTtl` | `number` | `3600` | Time-to-live for cached entries, in seconds. | +| `revalidate` | `number` | `1000` | Staleness window for cached collection versions, in ms.| +| `maxEntries` | `number` | `1000` | Maximum number of cached keys before older keys evict. | +| `keyPrefix` | `string` | `"em"` | Prefix for every cache key. | + +## What gets cached + +The object cache covers the reads that run on a typical page render: + +- Content queries: `getEmDashCollection`, `getEmDashEntry`, and `resolveEmDashPath`. +- Site settings, navigation menus, and taxonomy terms. + +Admin API requests, media files, and full HTML responses are not handled here. To cache rendered HTML at the edge, see [Deploy to Cloudflare](/deployment/cloudflare/). + +## Freshness + +Editing content through the admin panel or the REST API invalidates the affected cache entries automatically. Creating, updating, publishing, or deleting an entry clears the cached queries for its collection; changing a byline or taxonomy term clears the entries that display it. + + + +For anonymous visitors, a change can take up to `revalidate` milliseconds (default one second) to appear across all isolates. Lower `revalidate` for faster propagation at the cost of more reads against the cache; raise it to read the cache less often. + +### Scheduled content + +Scheduled entries become visible when their publish time passes. A cached page reflects a newly-published scheduled entry on the next change to its collection, or when the cached entry's `defaultTtl` lapses. If precise scheduled publishing matters for your site, set a lower `defaultTtl`. diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index 5dbb960597..32442b36fc 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -88,6 +88,22 @@ storage: s3({ See [Storage Options](/deployment/storage/) for details. +### `objectCache` + +**Optional.** Caches content and configuration query results in a key/value store so reads are served without querying the database on every request. Disabled when omitted. Choose one adapter: + +```js +// Cloudflare KV (shared across all isolates) +import { kvCache } from "@emdash-cms/cloudflare"; +objectCache: kvCache({ binding: "CACHE" }); + +// In-memory (Node.js / development) +import { memoryCache } from "emdash/astro"; +objectCache: memoryCache(); +``` + +See [Object Cache](/deployment/object-cache/) for setup and options. + ### `plugins` **Optional.** Array of EmDash plugins. The following example registers one plugin: @@ -561,6 +577,39 @@ not picked up. Workers deployments should either use the [`r2(config)`](#r2confi adapter or pass explicit values to `s3({...})`. See [Storage Options](/deployment/storage/#s3-compatible-storage) for details. +## Object cache adapters + +Pass one of these to the [`objectCache`](#objectcache) option. + +### `kvCache(config)` + +Cloudflare KV backend, shared across all isolates. Import from `@emdash-cms/cloudflare`. + +```js +kvCache({ + binding: "CACHE", // KV binding name (required) + defaultTtl: 3600, // entry TTL in seconds (optional, KV minimum 60) + revalidate: 1000, // cross-isolate staleness window in ms (optional) + timeout: 2000, // per-op timeout in ms before a miss (optional, 0 disables) + keyPrefix: "em", // cache key prefix (optional) +}) +``` + +### `memoryCache(config?)` + +In-process backend for Node.js and development. Import from `emdash/astro`. + +```js +memoryCache({ + defaultTtl: 3600, // entry TTL in seconds (optional) + revalidate: 1000, // staleness window in ms (optional) + maxEntries: 1000, // max cached keys before eviction (optional) + keyPrefix: "em", // cache key prefix (optional) +}) +``` + +See [Object Cache](/deployment/object-cache/) for setup and behavior. + ## Live collections Configure the EmDash loader in `src/live.config.ts`: From 649e19d8974e6006663f95f77edbe1b5e964f2ba Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Fri, 5 Jun 2026 20:34:05 -0700 Subject: [PATCH 04/15] feat(core): cache per-entry taxonomy reads in the object cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public renders that read an entry's terms still hit D1 on every request even with the object cache on: getEmDashEntry caches the entry (and bakes in byline/term hydration), but templates that call getEntryTerms / getTermsForEntries / getTerm directly fell through to D1, because only the taxonomy *definitions* (getTaxonomyDefs) and full term *lists* (getTaxonomyTerms) were wrapped. On a warm content-cache hit, hydration (which used to prime the request cache for getEntryTerms) doesn't run, so those direct calls query the database — a cache-busted load that should be served entirely from KV still pays D1 round-trips. Wrap the per-entry/term taxonomy reads in cachedQuery: - getEntryTerms, getTermsForEntries — namespaced under [content:, taxonomies]; assignments bump taxonomies and content writes bump content:, so they invalidate correctly. getEntryTerms keeps its requestCached wrapper so hydration priming still short-circuits within a request. - getTerm — namespaced under taxonomies (count is TTL-bounded). - getTermsForEntries returns a Map (not JSON-serializable): cache it as an array of [entryId, terms] pairs and rebuild the Map on read. Large id batches (which come from collection hydration, already served by the content cache) bypass the object cache to stay under KV's key-size limit. getEntriesByTerm already delegates to the cached getEmDashCollection, and getAllTermsForEntries only runs behind a content-cache miss, so neither needs separate wrapping. Test: with a configured backend, getEntryTerms and getTermsForEntries serve the second read from KV with D1 made unavailable, and the Map round-trips correctly. --- packages/core/src/taxonomies/index.ts | 190 +++++++++++------- .../entry-terms-object-cache.test.ts | 104 ++++++++++ 2 files changed, 223 insertions(+), 71 deletions(-) create mode 100644 packages/core/tests/unit/taxonomies/entry-terms-object-cache.test.ts diff --git a/packages/core/src/taxonomies/index.ts b/packages/core/src/taxonomies/index.ts index e6ac7350b3..2ed87882df 100644 --- a/packages/core/src/taxonomies/index.ts +++ b/packages/core/src/taxonomies/index.ts @@ -16,6 +16,7 @@ import { getDb } from "../loader.js"; import { cachedQuery, CacheNamespace, + contentNamespace, invalidateTaxonomyObjectCache, } from "../object-cache/index.js"; import { peekRequestCache, requestCached, setRequestCacheEntry } from "../request-cache.js"; @@ -191,8 +192,23 @@ export async function getTerm( slug: string, options: TaxonomyQueryOptions = {}, ): Promise { - const db = await getDb(); const chain = resolveLocaleChain(options.locale); + // Cached under the shared taxonomies epoch (bumped on any taxonomy / term + // assignment write). The `count` reflects content_taxonomies rows; a stale + // count after a bare content delete is bounded by the entry's TTL. + return cachedQuery({ + namespace: CacheNamespace.TAXONOMIES, + key: `term:${taxonomyName}:${slug}:${chain.join(",")}`, + load: () => loadTerm(taxonomyName, slug, chain), + }); +} + +async function loadTerm( + taxonomyName: string, + slug: string, + chain: string[], +): Promise { + const db = await getDb(); let row: Awaited["executeTakeFirst"]>>; const selectTerm = () => @@ -266,33 +282,46 @@ export function getEntryTerms( options: TaxonomyQueryOptions = {}, ): Promise { const locale = resolveLocale(options.locale); + // requestCached short-circuits to values primed by getAllTermsForEntries + // during entry hydration (same key shape). On a warm content-cache hit + // hydration doesn't run, so the inner cachedQuery serves this from KV + // instead of falling through to D1 on every request. return requestCached( `terms:${collection}:${entryId}:${taxonomyName ?? "*"}:${locale ?? "*"}`, - async () => { - const db = await getDb(); - - let query = db - .selectFrom("content_taxonomies") - .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id") - .selectAll("taxonomies") - .where("content_taxonomies.collection", "=", collection) - .where("content_taxonomies.entry_id", "=", entryId); - - if (taxonomyName) query = query.where("taxonomies.name", "=", taxonomyName); - if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale); - - const rows = await query.execute(); - return rows.map((row) => ({ - id: row.id, - name: row.name, - slug: row.slug, - label: row.label, - parentId: row.parent_id ?? undefined, - children: [], - locale: row.locale, - translationGroup: row.translation_group, - })); - }, + () => + cachedQuery({ + namespace: [contentNamespace(collection), CacheNamespace.TAXONOMIES], + key: `entryTerms:${collection}:${entryId}:${taxonomyName ?? "*"}:${locale ?? "*"}`, + load: async () => { + const db = await getDb(); + + let query = db + .selectFrom("content_taxonomies") + .innerJoin( + "taxonomies", + "taxonomies.translation_group", + "content_taxonomies.taxonomy_id", + ) + .selectAll("taxonomies") + .where("content_taxonomies.collection", "=", collection) + .where("content_taxonomies.entry_id", "=", entryId); + + if (taxonomyName) query = query.where("taxonomies.name", "=", taxonomyName); + if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale); + + const rows = await query.execute(); + return rows.map((row) => ({ + id: row.id, + name: row.name, + slug: row.slug, + label: row.label, + parentId: row.parent_id ?? undefined, + children: [], + locale: row.locale, + translationGroup: row.translation_group, + })); + }, + }), ); } @@ -305,57 +334,76 @@ export async function getTermsForEntries( taxonomyName: string, options: TaxonomyQueryOptions = {}, ): Promise> { - const result = new Map(); const uniqueIds = [...new Set(entryIds)]; - for (const id of uniqueIds) result.set(id, []); - if (uniqueIds.length === 0) return result; - - const db = await getDb(); + if (uniqueIds.length === 0) return new Map(); const locale = resolveLocale(options.locale); - for (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) { - let rows; - try { - let query = db - .selectFrom("content_taxonomies") - .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id") - .select([ - "content_taxonomies.entry_id", - "taxonomies.id", - "taxonomies.name", - "taxonomies.slug", - "taxonomies.label", - "taxonomies.parent_id", - "taxonomies.locale", - "taxonomies.translation_group", - ]) - .where("content_taxonomies.collection", "=", collection) - .where("content_taxonomies.entry_id", "in", chunk) - .where("taxonomies.name", "=", taxonomyName); - if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale); - rows = await query.execute(); - } catch (error) { - if (isMissingTableError(error)) return result; - throw error; - } + // The query result is a Map, which JSON can't represent — cache it as an + // array of [entryId, terms] pairs and rebuild the Map on read. + const load = async (): Promise> => { + const result = new Map(); + for (const id of uniqueIds) result.set(id, []); - for (const row of rows) { - const term: TaxonomyTerm = { - id: row.id, - name: row.name, - slug: row.slug, - label: row.label, - parentId: row.parent_id ?? undefined, - children: [], - locale: row.locale, - translationGroup: row.translation_group, - }; - const terms = result.get(row.entry_id); - if (terms) terms.push(term); + const db = await getDb(); + for (const chunk of chunks(uniqueIds, SQL_BATCH_SIZE)) { + let rows; + try { + let query = db + .selectFrom("content_taxonomies") + .innerJoin("taxonomies", "taxonomies.translation_group", "content_taxonomies.taxonomy_id") + .select([ + "content_taxonomies.entry_id", + "taxonomies.id", + "taxonomies.name", + "taxonomies.slug", + "taxonomies.label", + "taxonomies.parent_id", + "taxonomies.locale", + "taxonomies.translation_group", + ]) + .where("content_taxonomies.collection", "=", collection) + .where("content_taxonomies.entry_id", "in", chunk) + .where("taxonomies.name", "=", taxonomyName); + if (locale !== undefined) query = query.where("taxonomies.locale", "=", locale); + rows = await query.execute(); + } catch (error) { + if (isMissingTableError(error)) return [...result.entries()]; + throw error; + } + + for (const row of rows) { + const term: TaxonomyTerm = { + id: row.id, + name: row.name, + slug: row.slug, + label: row.label, + parentId: row.parent_id ?? undefined, + children: [], + locale: row.locale, + translationGroup: row.translation_group, + }; + const terms = result.get(row.entry_id); + if (terms) terms.push(term); + } } - } - return result; + return [...result.entries()]; + }; + + // Key on the sorted unique ids. Bound the key length: very large batches + // (rare; they come from collection hydration, already served by the content + // cache) bypass the object cache rather than blow past KV's key limit. + const idKey = uniqueIds.toSorted().join(","); + const pairs = + idKey.length <= 256 + ? await cachedQuery({ + namespace: [contentNamespace(collection), CacheNamespace.TAXONOMIES], + key: `termsForEntries:${collection}:${taxonomyName}:${locale ?? "*"}:${idKey}`, + load, + }) + : await load(); + + return new Map(pairs); } /** diff --git a/packages/core/tests/unit/taxonomies/entry-terms-object-cache.test.ts b/packages/core/tests/unit/taxonomies/entry-terms-object-cache.test.ts new file mode 100644 index 0000000000..9d8676ade2 --- /dev/null +++ b/packages/core/tests/unit/taxonomies/entry-terms-object-cache.test.ts @@ -0,0 +1,104 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true }); + +// Mock loader.getDb so the runtime taxonomy functions read from our test db +// (and so we can simulate D1 being unavailable on the second, cached read). +vi.mock("../../../src/loader.js", () => ({ getDb: vi.fn() })); + +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js"; +import type { Database } from "../../../src/database/types.js"; +import { getDb } from "../../../src/loader.js"; +import { + __setObjectCacheBackendForTests, + type ObjectCacheBackend, +} from "../../../src/object-cache/index.js"; +import { runWithContext } from "../../../src/request-context.js"; +import { getEntryTerms, getTermsForEntries } from "../../../src/taxonomies/index.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; + +function memoryBackend(): ObjectCacheBackend { + const store = new Map(); + return { + get: (k) => Promise.resolve(store.get(k) ?? null), + set: (k, v) => { + store.set(k, v); + return Promise.resolve(); + }, + delete: (k) => { + store.delete(k); + return Promise.resolve(); + }, + }; +} + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +describe("entry-term reads are served from the object cache", () => { + let db: Kysely; + let postId: string; + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + vi.mocked(getDb).mockResolvedValue(db); + + // Configure the object cache up front (wide revalidate window so the + // namespace epoch stays stable across reads). Injecting before any + // write means the seeding writes' deferred epoch bumps resolve the + // backend to this stub rather than triggering the virtual-module import. + __setObjectCacheBackendForTests(memoryBackend(), { revalidate: 60_000, defaultTtl: 3600 }); + + const taxRepo = new TaxonomyRepository(db); + const contentRepo = new ContentRepository(db); + const tag = await taxRepo.create({ name: "tag", slug: "web", label: "Web" }); + const post = await contentRepo.create({ type: "post", slug: "p1", data: { title: "P1" } }); + postId = post.id; + await taxRepo.attachToEntry("post", post.id, tag.id); + + // Let the seeding writes' deferred epoch bumps settle before the reads. + await flush(); + }); + + afterEach(async () => { + __setObjectCacheBackendForTests(null); + await teardownTestDatabase(db); + vi.restoreAllMocks(); + }); + + it("getEntryTerms serves the second read from KV without touching D1", async () => { + const first = await runWithContext({ editMode: false, db }, () => + getEntryTerms("post", postId, "tag"), + ); + expect(first.map((t) => t.slug)).toEqual(["web"]); + await flush(); // let the deferred cache set land + + // Simulate D1 being unavailable — a cached read must not need it. + vi.mocked(getDb).mockRejectedValue(new Error("D1 unavailable")); + + const second = await runWithContext({ editMode: false, db }, () => + getEntryTerms("post", postId, "tag"), + ); + expect(second.map((t) => t.slug)).toEqual(["web"]); + }); + + it("getTermsForEntries round-trips its Map through the cache (no D1 on hit)", async () => { + const first = await runWithContext({ editMode: false, db }, () => + getTermsForEntries("post", [postId], "tag"), + ); + expect(first.get(postId)?.map((t) => t.slug)).toEqual(["web"]); + await flush(); + + vi.mocked(getDb).mockRejectedValue(new Error("D1 unavailable")); + + const second = await runWithContext({ editMode: false, db }, () => + getTermsForEntries("post", [postId], "tag"), + ); + // Map rebuilt correctly from the cached array-of-pairs. + expect(second).toBeInstanceOf(Map); + expect(second.get(postId)?.map((t) => t.slug)).toEqual(["web"]); + }); +}); From 91d5cfc2cf2a8c1f407afb43312dac30994c4c51 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Fri, 5 Jun 2026 20:57:21 -0700 Subject: [PATCH 05/15] perf(core): fetch object-cache value and epochs in one parallel round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read path did two sequential KV round-trips per cached query: read the namespace epoch(s) to build the key, then read the value. On a cold isolate (epochs not yet cached in-memory) a page making several cached reads paid that doubled latency on each one. Make the value key epoch-independent and store the namespace epochs inside the value envelope ({ e: epochs, v: value }). A read now fetches the value and all epochs concurrently (Promise.all) and treats it as a HIT only when every stored epoch still matches the current one — one round-trip instead of two. Invalidation is unchanged from the caller's view (bump the epoch; the next read sees a mismatch and reloads), but a stale value is now overwritten in place under its stable key rather than orphaned under a dead epoch-keyed name — so KV no longer accumulates orphaned generations between TTL sweeps. Note this parallelizes the epoch/value reads *within* each cached query; ordering across a template's awaits is still the template's concern (use Promise.all for independent reads). Existing object-cache, content, taxonomy, and edge-cache tests pass unchanged (behavior is identical: hit after first load, reload after invalidation, multi-namespace busting, timeout-to-miss). --- packages/cloudflare/src/cache/kv.ts | 5 +- packages/core/src/object-cache/index.ts | 66 ++++++++++++++++++++----- packages/core/src/object-cache/types.ts | 7 +-- 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/packages/cloudflare/src/cache/kv.ts b/packages/cloudflare/src/cache/kv.ts index af32155ffa..387c7eb4e9 100644 --- a/packages/cloudflare/src/cache/kv.ts +++ b/packages/cloudflare/src/cache/kv.ts @@ -28,8 +28,9 @@ import type { CreateObjectCacheBackendFn, ObjectCacheBackend } from "emdash"; /** * Workers KV enforces a 60-second floor on `expirationTtl`. Clamp shorter TTLs - * up rather than letting `put` throw — epoch-based invalidation already - * orphans stale keys immediately, so a slightly longer backstop TTL is benign. + * up rather than letting `put` throw — invalidation is epoch-comparison-based + * (stale values are overwritten in place on read), so the TTL is only a + * backstop for never-re-read keys and a slightly longer one is benign. */ const KV_MIN_TTL_SECONDS = 60; diff --git a/packages/core/src/object-cache/index.ts b/packages/core/src/object-cache/index.ts index 6a228ff080..58205e1aa3 100644 --- a/packages/core/src/object-cache/index.ts +++ b/packages/core/src/object-cache/index.ts @@ -204,10 +204,37 @@ function epochKey(namespace: string): string { return `${holder.config.keyPrefix}:epoch:${namespace}`; } -/** Build the backend key for a cached value within one or more namespaces. */ -function valueKey(namespaces: readonly string[], epochs: readonly number[], key: string): string { - const sig = namespaces.map((ns, i) => `${ns}@${epochs[i]}`).join(","); - return `${holder.config.keyPrefix}:${sig}:${key}`; +/** + * Build the (epoch-independent) backend key for a cached value. + * + * The key is stable across invalidations — the namespace epochs are stored + * *inside* the value envelope and validated on read, not baked into the key. + * This lets the value and the epochs be fetched in one parallel round-trip + * (instead of "read epoch, then read value"), and means an invalidated value + * is overwritten in place rather than orphaned under a dead epoch-keyed name. + */ +function valueKey(namespaces: readonly string[], key: string): string { + return `${holder.config.keyPrefix}:${namespaces.join(",")}:${key}`; +} + +/** + * Stored cache envelope: the namespace epochs captured at write time alongside + * the cached value. A read is a HIT only when every stored epoch still matches + * the current epoch for its namespace. + */ +interface CacheEnvelope { + /** Epoch per namespace, in the query's namespace order. */ + e: number[]; + /** The cached value. */ + v: T; +} + +function epochsMatch(stored: readonly number[], current: readonly number[]): boolean { + if (stored.length !== current.length) return false; + for (let i = 0; i < stored.length; i++) { + if (stored[i] !== current[i]) return false; + } + return true; } /** @@ -309,31 +336,48 @@ export async function cachedQuery(options: CachedQueryOptions): Promise const namespaces = typeof options.namespace === "string" ? [options.namespace] : options.namespace; - const epochs = await Promise.all(namespaces.map((ns) => getEpoch(ns, backend))); - const fullKey = valueKey(namespaces, epochs, options.key); + const fullKey = valueKey(namespaces, options.key); + // Fetch the value and every namespace epoch concurrently — one round-trip + // instead of "read epochs, then read value". The value is a HIT only if its + // stored epochs still match the current ones. + let currentEpochs: number[] = []; try { - const raw = await withTimeout(backend.get(fullKey), holder.config.timeout, "read"); + const [raw, ...epochs] = await Promise.all([ + withTimeout(backend.get(fullKey), holder.config.timeout, "read"), + ...namespaces.map((ns) => getEpoch(ns, backend)), + ]); + currentEpochs = epochs; if (raw !== null) { const decoded = decode(raw); if (decoded !== undefined) { - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- key namespacing guarantees the stored value matches T - return decoded as T; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- value envelope written by this function + const envelope = decoded as CacheEnvelope; + if (epochsMatch(envelope.e, currentEpochs)) { + return envelope.v; + } } } } catch { // Treat backend read errors and timeouts as a miss — fall through to load(). + // currentEpochs may be empty; recompute below before storing. } const value = await options.load(); const cacheable = options.cacheable ? options.cacheable(value) : true; if (cacheable) { - const raw = encode(value); const ttl = options.ttl ?? holder.config.defaultTtl; - // Defer the write so it never adds to TTFB. + // Defer the write so it never adds to TTFB. Capture epochs at write time + // (re-read if the parallel read above failed) and store them with the + // value so a later read can detect staleness. after(async () => { try { + const epochs = + currentEpochs.length === namespaces.length + ? currentEpochs + : await Promise.all(namespaces.map((ns) => getEpoch(ns, backend))); + const raw = encode({ e: epochs, v: value } satisfies CacheEnvelope); await backend.set(fullKey, raw, ttl); } catch (error) { if (import.meta.env.DEV) { diff --git a/packages/core/src/object-cache/types.ts b/packages/core/src/object-cache/types.ts index e36dd88a0a..274200682d 100644 --- a/packages/core/src/object-cache/types.ts +++ b/packages/core/src/object-cache/types.ts @@ -62,9 +62,10 @@ export interface ObjectCacheRuntimeConfig { /** * Default time-to-live for cached entries, in seconds. * - * Epoch-based invalidation orphans stale keys instantly (see - * `./index.ts`), so the TTL is a backstop that reclaims orphaned keys and - * bounds staleness for anything not covered by an explicit epoch bump. + * Invalidation works by epoch comparison, not key deletion: a stale value + * is detected on read (its stored epoch no longer matches) and overwritten + * in place under the same key. The TTL is just a backstop that reclaims + * keys that are never read again. * * @default 3600 (1 hour) */ From 18164d6131e8ac2679461bc920a7e7a3a3096393 Mon Sep 17 00:00:00 2001 From: scottbuscemi Date: Fri, 5 Jun 2026 21:12:26 -0700 Subject: [PATCH 06/15] feat(core): cache collection-info and public comments in the object cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These were the last per-request D1 reads on a public post render. The component server-renders two reads on every page — even with content/taxonomy reads already served from KV: - getCollectionInfo (the commentsEnabled / supports / fields lookup), and - getComments (approved comments), when comments are enabled. Wrap both in cachedQuery: - getCollectionInfo → `schema` namespace, busted by invalidateUrlPatternCache (every schema-mutation path already routes through it, so editing a collection's settings/fields invalidates it). - getComments → `comments` namespace, busted by any CommentRepository write (create / status change / delete), so a new or moderated comment shows without waiting for TTL. With this, a warm-isolate logged-out post render makes no D1 query — the whole render is served from KV. Tests: getCollectionInfo and getComments serve the second read with D1 unavailable, and reload after a schema change / comment write respectively. --- packages/core/src/comments/query.ts | 11 +- .../core/src/database/repositories/comment.ts | 8 ++ packages/core/src/index.ts | 2 + packages/core/src/object-cache/index.ts | 14 ++ packages/core/src/query.ts | 11 +- packages/core/src/schema/query.ts | 15 ++- .../unit/object-cache-comments-schema.test.ts | 122 ++++++++++++++++++ 7 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 packages/core/tests/unit/object-cache-comments-schema.test.ts diff --git a/packages/core/src/comments/query.ts b/packages/core/src/comments/query.ts index 53a3cbf35c..f4d39150b9 100644 --- a/packages/core/src/comments/query.ts +++ b/packages/core/src/comments/query.ts @@ -11,6 +11,7 @@ import { CommentRepository } from "../database/repositories/comment.js"; import type { PublicComment } from "../database/repositories/comment.js"; import type { Database } from "../database/types.js"; import { getDb } from "../loader.js"; +import { cachedQuery, CacheNamespace } from "../object-cache/index.js"; export interface GetCommentsOptions { collection: string; @@ -38,8 +39,14 @@ export interface GetCommentsResult { * ``` */ export async function getComments(options: GetCommentsOptions): Promise { - const db = await getDb(); - return getCommentsWithDb(db, options); + return cachedQuery({ + namespace: CacheNamespace.COMMENTS, + key: `comments:${options.collection}:${options.contentId}:${options.threaded ? "t" : "f"}`, + load: async () => { + const db = await getDb(); + return getCommentsWithDb(db, options); + }, + }); } /** diff --git a/packages/core/src/database/repositories/comment.ts b/packages/core/src/database/repositories/comment.ts index e38f33fe2b..967df609f2 100644 --- a/packages/core/src/database/repositories/comment.ts +++ b/packages/core/src/database/repositories/comment.ts @@ -1,6 +1,7 @@ import { sql, type ExpressionBuilder, type Kysely } from "kysely"; import { ulid } from "ulidx"; +import { invalidateCommentObjectCache } from "../../object-cache/index.js"; import type { Database } from "../types.js"; import { encodeCursor, decodeCursor, type FindManyResult } from "./types.js"; @@ -99,6 +100,8 @@ export class CommentRepository { }) .execute(); + invalidateCommentObjectCache(); + const comment = await this.findById(id); if (!comment) { throw new Error("Failed to create comment"); @@ -237,6 +240,7 @@ export class CommentRepository { .where("id", "=", id) .execute(); + invalidateCommentObjectCache(); return this.findById(id); } @@ -254,6 +258,7 @@ export class CommentRepository { .where("id", "in", ids) .executeTakeFirst(); + invalidateCommentObjectCache(); return Number(result.numUpdatedRows ?? 0); } @@ -266,6 +271,7 @@ export class CommentRepository { .where("id", "=", id) .executeTakeFirst(); + invalidateCommentObjectCache(); return (result.numDeletedRows ?? 0) > 0; } @@ -280,6 +286,7 @@ export class CommentRepository { .where("id", "in", ids) .executeTakeFirst(); + invalidateCommentObjectCache(); return Number(result.numDeletedRows ?? 0); } @@ -293,6 +300,7 @@ export class CommentRepository { .where("content_id", "=", contentId) .executeTakeFirst(); + invalidateCommentObjectCache(); return Number(result.numDeletedRows ?? 0); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 015450b6bf..6229cfba66 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -199,6 +199,8 @@ export { invalidateTaxonomyObjectCache, invalidateBylineObjectCache, invalidateMenuObjectCache, + invalidateSchemaObjectCache, + invalidateCommentObjectCache, contentNamespace, contentNamespaces, CacheNamespace, diff --git a/packages/core/src/object-cache/index.ts b/packages/core/src/object-cache/index.ts index 58205e1aa3..93009cb073 100644 --- a/packages/core/src/object-cache/index.ts +++ b/packages/core/src/object-cache/index.ts @@ -434,6 +434,10 @@ export const CacheNamespace = { MENUS: "menus", TAXONOMIES: "taxonomies", BYLINES: "bylines", + /** Collection schema/metadata (label, supports, commentsEnabled, fields). */ + SCHEMA: "schema", + /** Public (approved) comments. */ + COMMENTS: "comments", } as const; /** Namespace for a content collection's cached queries. */ @@ -472,6 +476,16 @@ export function invalidateMenuObjectCache(): void { invalidateObjectCache(CacheNamespace.MENUS); } +/** Invalidate cached collection schema/metadata reads (e.g. getCollectionInfo). */ +export function invalidateSchemaObjectCache(): void { + invalidateObjectCache(CacheNamespace.SCHEMA); +} + +/** Invalidate cached public comment reads. */ +export function invalidateCommentObjectCache(): void { + invalidateObjectCache(CacheNamespace.COMMENTS); +} + export type { ObjectCacheBackend, ObjectCacheDescriptor, diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index b1812b8148..8b4c1a4a54 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -27,7 +27,11 @@ import { encodeCursor } from "./database/repositories/types.js"; import { getFallbackChain, getI18nConfig, isI18nEnabled } from "./i18n/config.js"; import { CURSOR_RAW_VALUES, type WhereRange, type WhereValue } from "./loader.js"; -import { cachedQuery, contentNamespaces } from "./object-cache/index.js"; +import { + cachedQuery, + contentNamespaces, + invalidateSchemaObjectCache, +} from "./object-cache/index.js"; import { requestCached } from "./request-cache.js"; import { getRequestContext } from "./request-context.js"; import { isMissingTableError } from "./utils/db-errors.js"; @@ -1087,9 +1091,14 @@ let cachedUrlPatterns: CachedPattern[] | null = null; /** * Invalidate the cached URL patterns used by resolveEmDashPath. * Call when collection URL patterns change (schema updates). + * + * Also busts the distributed schema cache (collection metadata such as + * `commentsEnabled`, `supports`, fields read by `getCollectionInfo`), since + * every schema-mutation path already routes through here. */ export function invalidateUrlPatternCache(): void { cachedUrlPatterns = null; + invalidateSchemaObjectCache(); } /** diff --git a/packages/core/src/schema/query.ts b/packages/core/src/schema/query.ts index 2485dcc31d..13f611b086 100644 --- a/packages/core/src/schema/query.ts +++ b/packages/core/src/schema/query.ts @@ -8,6 +8,7 @@ import type { Kysely } from "kysely"; import type { Database } from "../database/types.js"; import { getDb } from "../loader.js"; +import { cachedQuery, CacheNamespace } from "../object-cache/index.js"; import { requestCached } from "../request-cache.js"; import { SchemaRegistry } from "./registry.js"; import type { Collection } from "./types.js"; @@ -26,10 +27,16 @@ import type { Collection } from "./types.js"; * ``` */ export async function getCollectionInfo(slug: string): Promise { - return requestCached(`collection-info:${slug}`, async () => { - const db = await getDb(); - return getCollectionInfoWithDb(db, slug); - }); + return requestCached(`collection-info:${slug}`, () => + cachedQuery({ + namespace: CacheNamespace.SCHEMA, + key: `collection-info:${slug}`, + load: async () => { + const db = await getDb(); + return getCollectionInfoWithDb(db, slug); + }, + }), + ); } /** diff --git a/packages/core/tests/unit/object-cache-comments-schema.test.ts b/packages/core/tests/unit/object-cache-comments-schema.test.ts new file mode 100644 index 0000000000..360306487a --- /dev/null +++ b/packages/core/tests/unit/object-cache-comments-schema.test.ts @@ -0,0 +1,122 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true }); +vi.mock("../../src/loader.js", () => ({ getDb: vi.fn() })); + +import { getComments } from "../../src/comments/query.js"; +import { CommentRepository } from "../../src/database/repositories/comment.js"; +import { ContentRepository } from "../../src/database/repositories/content.js"; +import type { Database } from "../../src/database/types.js"; +import { getDb } from "../../src/loader.js"; +import { + __setObjectCacheBackendForTests, + type ObjectCacheBackend, +} from "../../src/object-cache/index.js"; +import { invalidateUrlPatternCache } from "../../src/query.js"; +import { getCollectionInfo } from "../../src/schema/query.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../utils/test-db.js"; + +function memoryBackend(): ObjectCacheBackend { + const store = new Map(); + return { + get: (k) => Promise.resolve(store.get(k) ?? null), + set: (k, v) => { + store.set(k, v); + return Promise.resolve(); + }, + delete: (k) => { + store.delete(k); + return Promise.resolve(); + }, + }; +} +const flush = () => new Promise((r) => setTimeout(r, 0)); + +describe("object cache: schema (getCollectionInfo)", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + vi.mocked(getDb).mockResolvedValue(db); + __setObjectCacheBackendForTests(memoryBackend(), { revalidate: 60_000, defaultTtl: 3600 }); + }); + afterEach(async () => { + __setObjectCacheBackendForTests(null); + await teardownTestDatabase(db); + vi.restoreAllMocks(); + }); + + it("serves the second read from KV, and busts on a schema change", async () => { + const first = await getCollectionInfo("post"); + expect(first?.slug).toBe("post"); + await flush(); + + // D1 down — a cached read must not need it. + vi.mocked(getDb).mockRejectedValue(new Error("D1 unavailable")); + const second = await getCollectionInfo("post"); + expect(second?.slug).toBe("post"); + + // A schema change bumps the schema epoch → next read reloads (and now + // D1 is down, so it surfaces). + invalidateUrlPatternCache(); + await flush(); + await expect(getCollectionInfo("post")).rejects.toThrow(/D1 unavailable/); + }); +}); + +describe("object cache: comments (getComments)", () => { + let db: Kysely; + let postId: string; + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + vi.mocked(getDb).mockResolvedValue(db); + __setObjectCacheBackendForTests(memoryBackend(), { revalidate: 60_000, defaultTtl: 3600 }); + + const post = await new ContentRepository(db).create({ + type: "post", + slug: "p1", + data: { title: "P1" }, + }); + postId = post.id; + await new CommentRepository(db).create({ + collection: "post", + contentId: postId, + authorName: "A", + authorEmail: "a@example.com", + body: "first!", + status: "approved", + }); + await flush(); + }); + afterEach(async () => { + __setObjectCacheBackendForTests(null); + await teardownTestDatabase(db); + vi.restoreAllMocks(); + }); + + it("serves the second read from KV, and busts when a comment is written", async () => { + const first = await getComments({ collection: "post", contentId: postId }); + expect(first.total).toBe(1); + await flush(); + + vi.mocked(getDb).mockRejectedValue(new Error("D1 unavailable")); + const second = await getComments({ collection: "post", contentId: postId }); + expect(second.total).toBe(1); // served from KV, no D1 + + // Posting another comment bumps the comments epoch → reload (D1 down). + vi.mocked(getDb).mockResolvedValue(db); + await new CommentRepository(db).create({ + collection: "post", + contentId: postId, + authorName: "B", + authorEmail: "b@example.com", + body: "second!", + status: "approved", + }); + await flush(); + const third = await getComments({ collection: "post", contentId: postId }); + expect(third.total).toBe(2); + }); +}); From e829a867930d459a45d18448b504284d6c5ce0c9 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 22 Jun 2026 08:19:09 +0100 Subject: [PATCH 07/15] fix(core): prevent a stale in-flight epoch read from reverting an invalidation An object-cache epoch read started before `invalidateObjectCache` could resolve afterwards and unconditionally write the pre-bump backend value over the freshly-bumped local epoch, resurrecting values the invalidation had just orphaned (stale content served until the value TTL, default 1h). Epochs are monotonic, so the resolved read now merges with the current cached epoch via Math.max and never lowers it. --- packages/core/src/object-cache/index.ts | 16 +++--- packages/core/tests/unit/object-cache.test.ts | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/core/src/object-cache/index.ts b/packages/core/src/object-cache/index.ts index 5a0c88695c..d522fd3536 100644 --- a/packages/core/src/object-cache/index.ts +++ b/packages/core/src/object-cache/index.ts @@ -266,6 +266,7 @@ async function getEpoch(namespace: string, backend: ObjectCacheBackend): Promise if (cached?.promise) return cached.promise; const promise = (async () => { + let value: number; try { const raw = await withTimeout( backend.get(epochKey(namespace)), @@ -273,14 +274,17 @@ async function getEpoch(namespace: string, backend: ObjectCacheBackend): Promise "epoch read", ); const parsed = raw === null ? 0 : Number(raw); - const value = Number.isFinite(parsed) ? parsed : 0; - epochCache.set(namespace, { value, at: Date.now() }); - return value; + value = Number.isFinite(parsed) ? parsed : 0; } catch { - const fallback = cached?.value ?? 0; - epochCache.set(namespace, { value: fallback, at: Date.now() }); - return fallback; + value = cached?.value ?? 0; } + // A concurrent invalidateObjectCache may have bumped the epoch while this + // read was in flight. Epochs are monotonic, so never let a stale backend + // read lower a freshly-bumped local epoch — that would resurrect the very + // values the bump just invalidated. + const merged = Math.max(value, epochCache.get(namespace)?.value ?? 0); + epochCache.set(namespace, { value: merged, at: Date.now() }); + return merged; })(); // Concurrent callers share this in-flight read (dedup). The timeout above diff --git a/packages/core/tests/unit/object-cache.test.ts b/packages/core/tests/unit/object-cache.test.ts index 70dc9644e1..7f0964eeea 100644 --- a/packages/core/tests/unit/object-cache.test.ts +++ b/packages/core/tests/unit/object-cache.test.ts @@ -179,6 +179,58 @@ describe("cachedQuery", () => { expect(load).toHaveBeenCalledTimes(2); }); + it("does not let a stale in-flight epoch read clobber a concurrent invalidation", async () => { + // An epoch read started before an invalidation must not, on resolving with + // the pre-bump backend value, lower the freshly-bumped local epoch — that + // would resurrect the values the invalidation just orphaned. + const store = new Map(); + let releaseEpoch: ((v: string | null) => void) | undefined; + let gate = false; + const backend: ObjectCacheBackend = { + get: (key) => { + if (gate && key.includes(":epoch:")) { + return new Promise((resolve) => { + releaseEpoch = resolve; + }); + } + return Promise.resolve(store.get(key) ?? null); + }, + set: (key, value) => { + store.set(key, value); + return Promise.resolve(); + }, + delete: (key) => { + store.delete(key); + return Promise.resolve(); + }, + }; + __setObjectCacheBackendForTests(backend, { revalidate: 0, defaultTtl: 3600 }); + + const load = vi.fn(() => Promise.resolve({ n: Math.random() })); + + // Prime: value stored under epoch 0. + const primed = await cachedQuery({ namespace: "posts", key: "k", load }); + await flush(); + + // Start a query whose epoch read parks in flight. + gate = true; + const inflight = cachedQuery({ namespace: "posts", key: "k", load }); + await flush(); + + // Invalidate mid-flight: bumps the local epoch above 0. + invalidateObjectCache("posts"); + + // The parked epoch read now resolves with the stale backend epoch. + releaseEpoch?.(null); + const inflightResult = await inflight; + await flush(); + + // The bump must survive: the in-flight query reloads rather than serving + // the pre-invalidation value. + expect(inflightResult).not.toEqual(primed); + expect(load).toHaveBeenCalledTimes(2); + }); + it("treats a backend read error as a miss without throwing", async () => { const backend = spyBackend(); backend.get = vi.fn(() => Promise.reject(new Error("kv down"))); From 3f2e55296126d2d84494a9f1ce74a5a4ba82711c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 22 Jun 2026 08:22:27 +0100 Subject: [PATCH 08/15] fix(core): capture object-cache epochs before load on the read-error path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the value read errored or timed out, `cachedQuery` left `currentEpochs` empty and re-read the epochs in the deferred write — *after* `load()`. A write that invalidated the namespace mid-load would then be picked up by that re-read and stamp the stale value under the new epoch, so a later read served it as a HIT. The value and epoch reads now run concurrently but are awaited separately (getEpoch never rejects), so the pre-load epochs are always captured, even when the value read fails. --- packages/core/src/object-cache/index.ts | 54 ++++++++--------- packages/core/tests/unit/object-cache.test.ts | 58 +++++++++++++++++++ 2 files changed, 82 insertions(+), 30 deletions(-) diff --git a/packages/core/src/object-cache/index.ts b/packages/core/src/object-cache/index.ts index d522fd3536..57fd29be16 100644 --- a/packages/core/src/object-cache/index.ts +++ b/packages/core/src/object-cache/index.ts @@ -342,29 +342,27 @@ export async function cachedQuery(options: CachedQueryOptions): Promise typeof options.namespace === "string" ? [options.namespace] : options.namespace; const fullKey = valueKey(namespaces, options.key); - // Fetch the value and every namespace epoch concurrently — one round-trip - // instead of "read epochs, then read value". The value is a HIT only if its - // stored epochs still match the current ones. - let currentEpochs: number[] = []; - try { - const [raw, ...epochs] = await Promise.all([ - withTimeout(backend.get(fullKey), holder.config.timeout, "read"), - ...namespaces.map((ns) => getEpoch(ns, backend)), - ]); - currentEpochs = epochs; - if (raw !== null) { - const decoded = decode(raw); - if (decoded !== undefined) { - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- value envelope written by this function - const envelope = decoded as CacheEnvelope; - if (epochsMatch(envelope.e, currentEpochs)) { - return envelope.v; - } + // Kick off the value read and every namespace epoch read concurrently — one + // round-trip instead of "read epochs, then read value". getEpoch never + // rejects, so awaiting the epochs separately from the value read guarantees + // we hold the pre-load epochs even when the value read errors or times out. + // Storing a value under an epoch read *after* load() would mask a write that + // landed during load(): the stale value would match and be served as a HIT. + const epochsPromise = Promise.all(namespaces.map((ns) => getEpoch(ns, backend))); + const rawPromise = withTimeout(backend.get(fullKey), holder.config.timeout, "read").catch( + () => null, + ); + const currentEpochs = await epochsPromise; + const raw = await rawPromise; + if (raw !== null) { + const decoded = decode(raw); + if (decoded !== undefined) { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- value envelope written by this function + const envelope = decoded as CacheEnvelope; + if (epochsMatch(envelope.e, currentEpochs)) { + return envelope.v; } } - } catch { - // Treat backend read errors and timeouts as a miss — fall through to load(). - // currentEpochs may be empty; recompute below before storing. } const value = await options.load(); @@ -372,17 +370,13 @@ export async function cachedQuery(options: CachedQueryOptions): Promise const cacheable = options.cacheable ? options.cacheable(value) : true; if (cacheable) { const ttl = options.ttl ?? holder.config.defaultTtl; - // Defer the write so it never adds to TTFB. Capture epochs at write time - // (re-read if the parallel read above failed) and store them with the - // value so a later read can detect staleness. + // Defer the write so it never adds to TTFB. The epochs were captured + // before load() ran, so a write that invalidated this namespace mid-load + // correctly orphans the value stored here. after(async () => { try { - const epochs = - currentEpochs.length === namespaces.length - ? currentEpochs - : await Promise.all(namespaces.map((ns) => getEpoch(ns, backend))); - const raw = encode({ e: epochs, v: value } satisfies CacheEnvelope); - await backend.set(fullKey, raw, ttl); + const encoded = encode({ e: currentEpochs, v: value } satisfies CacheEnvelope); + await backend.set(fullKey, encoded, ttl); } catch (error) { if (import.meta.env.DEV) { console.warn("[object-cache] set failed:", error); diff --git a/packages/core/tests/unit/object-cache.test.ts b/packages/core/tests/unit/object-cache.test.ts index 7f0964eeea..29d61f536e 100644 --- a/packages/core/tests/unit/object-cache.test.ts +++ b/packages/core/tests/unit/object-cache.test.ts @@ -231,6 +231,64 @@ describe("cachedQuery", () => { expect(load).toHaveBeenCalledTimes(2); }); + it("captures epochs before load even when the value read fails", async () => { + // When the value read errors, the epochs must still be captured *before* + // load() runs. Re-reading them afterwards would pick up a write that + // landed mid-load and stamp the stale value under the new epoch, so a + // later read would serve it as a HIT. + const store = new Map(); + let failValueGet = true; + let releaseLoad: (() => void) | undefined; + const backend: ObjectCacheBackend = { + get: (key) => { + if (key.includes(":epoch:")) return Promise.resolve(store.get(key) ?? null); + if (failValueGet) return Promise.reject(new Error("value get down")); + return Promise.resolve(store.get(key) ?? null); + }, + set: (key, value) => { + store.set(key, value); + return Promise.resolve(); + }, + delete: (key) => { + store.delete(key); + return Promise.resolve(); + }, + }; + __setObjectCacheBackendForTests(backend, { revalidate: 0, defaultTtl: 3600 }); + + let n = 0; + let parkFirst = true; + const load = vi.fn(() => { + const v = ++n; + if (parkFirst) { + parkFirst = false; + return new Promise<{ n: number }>((resolve) => { + releaseLoad = () => resolve({ n: v }); + }); + } + return Promise.resolve({ n: v }); + }); + + // Value read rejects → load path. Hold load open. + const q1 = cachedQuery<{ n: number }>({ namespace: "posts", key: "k", load }); + await flush(); + + // A write invalidates the namespace while load is in flight. + invalidateObjectCache("posts"); + await flush(); + + releaseLoad?.(); + const first = await q1; + await flush(); + + // Value reads work again; the value cached during the load must have been + // stamped with the pre-load epoch, so the bump orphans it and we reload. + failValueGet = false; + const second = await cachedQuery<{ n: number }>({ namespace: "posts", key: "k", load }); + expect(second).not.toEqual(first); + expect(load).toHaveBeenCalledTimes(2); + }); + it("treats a backend read error as a miss without throwing", async () => { const backend = spyBackend(); backend.get = vi.fn(() => Promise.reject(new Error("kv down"))); From aa4b5fd32780ccfb7f2fd0f62b2f54c2c1d8d1d0 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 22 Jun 2026 09:27:34 +0100 Subject: [PATCH 09/15] fix(core): don't cache a scheduled entry that isn't visible yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scheduled entry becomes visible on a future clock tick, not on a write, so an object-cache snapshot taken before its go-live time kept it hidden past that time — until the publish sweep bumped the epoch or the value TTL (default 1h) lapsed. getEmDashEntry now marks a resolution time-sensitive when it sees a scheduled, not-yet-due candidate and skips caching that result. --- packages/core/src/query.ts | 19 +++++++++- .../tests/unit/object-cache-content.test.ts | 36 +++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index e47cdf4799..ab51ca66e3 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -733,6 +733,14 @@ export async function getEmDashEntry): boolean { + const data = entryData(entry); + if (dataStr(data, "status") !== "scheduled") return false; + const scheduledAt = dataDate(data, "scheduledAt"); + return scheduledAt !== undefined && scheduledAt.getTime() > Date.now(); + } + // Build the fallback chain: [requestedLocale, fallback1, ..., defaultLocale] // When i18n is disabled or no locale requested, just use a single-element chain const localeChain = @@ -839,6 +847,12 @@ export async function getEmDashEntry