Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
76b13e0
feat(core): optional distributed object cache for query results
scottbuscemi Jun 6, 2026
30944dc
fix(core): bound object-cache backend reads with a timeout
scottbuscemi Jun 6, 2026
1cda8d6
docs: document the object cache
scottbuscemi Jun 6, 2026
649e19d
feat(core): cache per-entry taxonomy reads in the object cache
scottbuscemi Jun 6, 2026
91d5cfc
perf(core): fetch object-cache value and epochs in one parallel round…
scottbuscemi Jun 6, 2026
18164d6
feat(core): cache collection-info and public comments in the object c…
scottbuscemi Jun 6, 2026
b0a9a73
Merge remote-tracking branch 'origin/main' into feat/object-cache
ascorbic Jun 22, 2026
e829a86
fix(core): prevent a stale in-flight epoch read from reverting an inv…
ascorbic Jun 22, 2026
3f2e552
fix(core): capture object-cache epochs before load on the read-error …
ascorbic Jun 22, 2026
aa4b5fd
fix(core): don't cache a scheduled entry that isn't visible yet
ascorbic Jun 22, 2026
6eb54c1
fix(core): invalidate the content cache on field schema changes
ascorbic Jun 22, 2026
4726b7e
fix(core): don't collapse a multi-key object carrying the date tag
ascorbic Jun 22, 2026
aee98f9
docs(core): correct the memory backend's expiry-clock comment
ascorbic Jun 22, 2026
1365050
Merge branch 'main' into feat/object-cache
ascorbic Jun 22, 2026
9c67616
fix(core): include reactions and sort in the getComments cache key
ascorbic Jun 22, 2026
5a1517d
fix(core): invalidate the comments cache when a reaction is toggled
ascorbic Jun 22, 2026
a1eecf1
docs(core): correct object-cache bypass and staleness claims
ascorbic Jun 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/object-cache.md
Original file line number Diff line number Diff line change
@@ -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": "<namespace-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). Preview and visual-edit requests bypass the cache, so editors previewing see live content; other reads are served from the cache, which only ever stores published content. After an edit, anonymous visitors may see stale content until isolates pick up the bumped epoch — immediate on the in-isolate memory backend, and on KV bounded by KV's edge-cache propagation (eventually consistent, up to ~60s) plus the `revalidate` window (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.
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
},
{
Expand Down
16 changes: 16 additions & 0 deletions docs/src/content/docs/deployment/cloudflare.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
130 changes: 130 additions & 0 deletions docs/src/content/docs/deployment/object-cache.mdx
Original file line number Diff line number Diff line change
@@ -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`:

<Tabs>
<TabItem label="wrangler.jsonc">
```jsonc
{
"kv_namespaces": [
{
"binding": "CACHE",
"id": "<namespace-id>"
}
]
}
```
</TabItem>
<TabItem label="wrangler.toml">
```toml
[[kv_namespaces]]
binding = "CACHE"
id = "<namespace-id>"
```
</TabItem>
</Tabs>

### 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` | Isolate-local epoch-reuse 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` | Isolate-local epoch-reuse window, 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.

<Aside type="note">
Preview links and visual editing bypass the object cache, so editors previewing see the current content immediately. Other requests — including authenticated browsing outside edit mode — are served from the cache, which only ever stores published content.
</Aside>

For anonymous visitors, a change takes time to appear across all isolates as they pick up the bumped epoch. With the in-isolate memory backend this is immediate. With Workers KV it is bounded by KV's edge-cache propagation (eventual consistency, up to ~60 seconds) plus the isolate-local `revalidate` window (default one second). Lower `revalidate` for faster local 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`.
49 changes: 49 additions & 0 deletions docs/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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`:
Expand Down
4 changes: 4 additions & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,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": {
Expand Down
99 changes: 99 additions & 0 deletions packages/cloudflare/src/cache/kv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* 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 — 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;

/**
* 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<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
if (!(ms > 0)) return promise;
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_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) {
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<string, unknown>)[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": "<namespace-id>" }]\n}\n\n` +
`and ensure you're running on Cloudflare Workers.`,
);
}

const timeout =
typeof config.timeout === "number" && config.timeout >= 0
? config.timeout
: DEFAULT_KV_TIMEOUT_MS;

return {
async get(key: string): Promise<string | null> {
return (await withTimeout(kv.get(key, "text"), timeout, "get")) ?? null;
},
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
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<void> {
await withTimeout(kv.delete(key), timeout, "delete");
},
};
};
Loading
Loading