Skip to content

Commit 640e60a

Browse files
feat(core): optional distributed object cache for query results (#1378)
* 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. * 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. * docs: document the object cache * feat(core): cache per-entry taxonomy reads in the object cache 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:<collection>, taxonomies]; assignments bump taxonomies and content writes bump content:<collection>, 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. * perf(core): fetch object-cache value and epochs in one parallel round-trip 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). * feat(core): cache collection-info and public comments in the object cache These were the last per-request D1 reads on a public post render. The <Comments> 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. * 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. * fix(core): capture object-cache epochs before load on the read-error path 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. * fix(core): don't cache a scheduled entry that isn't visible yet 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. * fix(core): invalidate the content cache on field schema changes Creating, updating, or deleting a field changes the collection's columns, but the field handlers invalidated nothing — cached content snapshots (which embed field values) kept serving the old shape to anonymous visitors until a content write or the value TTL. The field create/update/delete handlers now bump the collection's content cache. Reorder is untouched: field order isn't part of any cached read. * fix(core): don't collapse a multi-key object carrying the date tag The object-cache codec rehydrated any object with a `$$emdashDate` string key into a Date, so a user value that happened to carry that key alongside others lost every other field. encode() only ever emits the tag as an object's sole key, so decode now requires exactly one key before treating it as a tagged Date. * docs(core): correct the memory backend's expiry-clock comment The memory backend computes expiry from `Date.now()`, not `performance.now()`. * fix(core): include reactions and sort in the getComments cache key getCommentsWithDb branches on `reactions` and `sort` ("best" reorders and implies reactions), but the object-cache key folded in only collection, contentId, and threaded. Two reads differing solely in those options collided, so the second was served the first's snapshot (e.g. a `sort: "best"` render getting oldest-ordered, reaction-less comments). The key now includes both, normalizing the best-implies-reactions rule so identical results still share an entry. * fix(core): invalidate the comments cache when a reaction is toggled Reaction counts and `best` ordering are folded into cached getComments reads, but handleReactionToggle never invalidated — so a toggled reaction left stale counts (and stale "best" order) until a comment write or the value TTL. The toggle handler now bumps the comments cache. * docs(core): correct object-cache bypass and staleness claims Two claims were inaccurate: `shouldBypass` checks only edit/preview/isolated mode, not authentication, so authenticated browsing outside edit mode is served from the cache (safe — it only stores published content); and `revalidate` bounds only an isolate's local epoch reuse, not total cross-isolate staleness. On KV the real window is dominated by KV's edge-cache propagation (eventually consistent, up to ~60s) plus `revalidate`. Updated the runtime JSDoc, the revalidate type doc, and the deployment guide to say so. --------- Co-authored-by: Matt Kane <m@mk.gg> Co-authored-by: Matt Kane <mkane@cloudflare.com>
1 parent cf17c9f commit 640e60a

41 files changed

Lines changed: 2786 additions & 204 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/object-cache.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"emdash": minor
3+
"@emdash-cms/cloudflare": minor
4+
---
5+
6+
Add an optional distributed object cache for query results.
7+
8+
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.
9+
10+
The cache is **off by default** and fully opt-in. Configure a backend in `astro.config.mjs`:
11+
12+
```ts
13+
import { kvCache } from "@emdash-cms/cloudflare"; // Workers KV (distributed)
14+
import { memoryCache } from "emdash/astro"; // in-isolate (Node / local dev)
15+
16+
emdash({
17+
database: d1({ binding: "DB" }),
18+
objectCache: kvCache({ binding: "CACHE" }),
19+
});
20+
```
21+
22+
with a matching KV binding in `wrangler.jsonc`:
23+
24+
```jsonc
25+
{ "kv_namespaces": [{ "binding": "CACHE", "id": "<namespace-id>" }] }
26+
```
27+
28+
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).
29+
30+
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.

docs/astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ export default defineConfig({
220220
{ label: "Deploy to Node.js", slug: "deployment/nodejs" },
221221
{ label: "Database Options", slug: "deployment/database" },
222222
{ label: "Storage Options", slug: "deployment/storage" },
223+
{ label: "Object Cache", slug: "deployment/object-cache" },
223224
],
224225
},
225226
{

docs/src/content/docs/deployment/cloudflare.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,22 @@ You also need to enable read replication on the D1 database itself in the Cloudf
9696

9797
See [Database Options — Read Replicas](/deployment/database/#read-replicas) for session modes and how bookmark-based consistency works.
9898

99+
## Object Cache
100+
101+
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:
102+
103+
```js title="astro.config.mjs"
104+
import { d1, r2, kvCache } from "@emdash-cms/cloudflare";
105+
106+
emdash({
107+
database: d1({ binding: "DB" }),
108+
storage: r2({ binding: "MEDIA" }),
109+
objectCache: kvCache({ binding: "CACHE" }),
110+
}),
111+
```
112+
113+
See [Object Cache](/deployment/object-cache/) for KV setup, options, and invalidation behavior.
114+
99115
## Custom Domain
100116

101117
Add a custom domain in the Cloudflare dashboard:
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
---
2+
title: Object Cache
3+
description: Cache query results in Cloudflare KV or memory to serve reads without querying the database on every request.
4+
---
5+
6+
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
7+
8+
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.
9+
10+
The object cache is optional and disabled by default. Enable it by adding an `objectCache` adapter to the `emdash()` integration.
11+
12+
## Overview
13+
14+
| Backend | Best for | Shared across isolates |
15+
| ---------- | --------------------------------- | ---------------------- |
16+
| **KV** | Cloudflare Workers | Yes |
17+
| **Memory** | Node.js, local development | No (per process) |
18+
19+
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.
20+
21+
## Cloudflare KV
22+
23+
Configure the KV adapter and point it at a KV binding:
24+
25+
```js title="astro.config.mjs"
26+
import emdash from "emdash/astro";
27+
import { d1, r2, kvCache } from "@emdash-cms/cloudflare";
28+
29+
export default defineConfig({
30+
integrations: [
31+
emdash({
32+
database: d1({ binding: "DB" }),
33+
storage: r2({ binding: "MEDIA" }),
34+
objectCache: kvCache({ binding: "CACHE" }),
35+
}),
36+
],
37+
});
38+
```
39+
40+
### Setup
41+
42+
Create a KV namespace and add the binding to your Wrangler configuration.
43+
44+
```sh
45+
npx wrangler kv namespace create CACHE
46+
```
47+
48+
The command prints a namespace `id`. Add it under the binding name used in `kvCache`:
49+
50+
<Tabs>
51+
<TabItem label="wrangler.jsonc">
52+
```jsonc
53+
{
54+
"kv_namespaces": [
55+
{
56+
"binding": "CACHE",
57+
"id": "<namespace-id>"
58+
}
59+
]
60+
}
61+
```
62+
</TabItem>
63+
<TabItem label="wrangler.toml">
64+
```toml
65+
[[kv_namespaces]]
66+
binding = "CACHE"
67+
id = "<namespace-id>"
68+
```
69+
</TabItem>
70+
</Tabs>
71+
72+
### Options
73+
74+
| Option | Type | Default | Description |
75+
| ------------ | -------- | -------- | --------------------------------------------------------------------------------- |
76+
| `binding` | `string` || KV binding name from your Wrangler configuration. Required. |
77+
| `defaultTtl` | `number` | `3600` | Time-to-live for cached entries, in seconds. KV enforces a 60-second minimum. |
78+
| `revalidate` | `number` | `1000` | Isolate-local epoch-reuse window, in milliseconds. See [Freshness](#freshness). |
79+
| `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. |
80+
| `keyPrefix` | `string` | `"em"` | Prefix for every cache key. Set a unique value when several sites share one namespace. |
81+
82+
## Node.js (memory)
83+
84+
The memory adapter caches within the server process. It needs no external service:
85+
86+
```js title="astro.config.mjs"
87+
import emdash, { memoryCache } from "emdash/astro";
88+
import { sqlite } from "emdash/db";
89+
90+
export default defineConfig({
91+
integrations: [
92+
emdash({
93+
database: sqlite({ url: "file:./data.db" }),
94+
objectCache: memoryCache(),
95+
}),
96+
],
97+
});
98+
```
99+
100+
### Options
101+
102+
| Option | Type | Default | Description |
103+
| ------------ | -------- | ------- | ------------------------------------------------------ |
104+
| `defaultTtl` | `number` | `3600` | Time-to-live for cached entries, in seconds. |
105+
| `revalidate` | `number` | `1000` | Isolate-local epoch-reuse window, in ms.|
106+
| `maxEntries` | `number` | `1000` | Maximum number of cached keys before older keys evict. |
107+
| `keyPrefix` | `string` | `"em"` | Prefix for every cache key. |
108+
109+
## What gets cached
110+
111+
The object cache covers the reads that run on a typical page render:
112+
113+
- Content queries: `getEmDashCollection`, `getEmDashEntry`, and `resolveEmDashPath`.
114+
- Site settings, navigation menus, and taxonomy terms.
115+
116+
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/).
117+
118+
## Freshness
119+
120+
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.
121+
122+
<Aside type="note">
123+
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.
124+
</Aside>
125+
126+
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.
127+
128+
### Scheduled content
129+
130+
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`.

docs/src/content/docs/reference/configuration.mdx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,22 @@ storage: s3({
8888

8989
See [Storage Options](/deployment/storage/) for details.
9090

91+
### `objectCache`
92+
93+
**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:
94+
95+
```js
96+
// Cloudflare KV (shared across all isolates)
97+
import { kvCache } from "@emdash-cms/cloudflare";
98+
objectCache: kvCache({ binding: "CACHE" });
99+
100+
// In-memory (Node.js / development)
101+
import { memoryCache } from "emdash/astro";
102+
objectCache: memoryCache();
103+
```
104+
105+
See [Object Cache](/deployment/object-cache/) for setup and options.
106+
91107
### `plugins`
92108

93109
**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
561577
adapter or pass explicit values to `s3({...})`. See
562578
[Storage Options](/deployment/storage/#s3-compatible-storage) for details.
563579

580+
## Object cache adapters
581+
582+
Pass one of these to the [`objectCache`](#objectcache) option.
583+
584+
### `kvCache(config)`
585+
586+
Cloudflare KV backend, shared across all isolates. Import from `@emdash-cms/cloudflare`.
587+
588+
```js
589+
kvCache({
590+
binding: "CACHE", // KV binding name (required)
591+
defaultTtl: 3600, // entry TTL in seconds (optional, KV minimum 60)
592+
revalidate: 1000, // cross-isolate staleness window in ms (optional)
593+
timeout: 2000, // per-op timeout in ms before a miss (optional, 0 disables)
594+
keyPrefix: "em", // cache key prefix (optional)
595+
})
596+
```
597+
598+
### `memoryCache(config?)`
599+
600+
In-process backend for Node.js and development. Import from `emdash/astro`.
601+
602+
```js
603+
memoryCache({
604+
defaultTtl: 3600, // entry TTL in seconds (optional)
605+
revalidate: 1000, // staleness window in ms (optional)
606+
maxEntries: 1000, // max cached keys before eviction (optional)
607+
keyPrefix: "em", // cache key prefix (optional)
608+
})
609+
```
610+
611+
See [Object Cache](/deployment/object-cache/) for setup and behavior.
612+
564613
## Live collections
565614

566615
Configure the EmDash loader in `src/live.config.ts`:

packages/cloudflare/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@
7272
"./cache/config": {
7373
"types": "./dist/cache/config.d.mts",
7474
"default": "./dist/cache/config.mjs"
75+
},
76+
"./cache/kv": {
77+
"types": "./dist/cache/kv.d.mts",
78+
"default": "./dist/cache/kv.mjs"
7579
}
7680
},
7781
"scripts": {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Cloudflare KV object-cache backend — RUNTIME ENTRY
3+
*
4+
* Backs EmDash's distributed object cache with a Workers KV namespace. KV is
5+
* globally replicated and built for high read volume, making it the right
6+
* place to absorb content/chrome reads that would otherwise hammer D1.
7+
*
8+
* This module imports `cloudflare:workers` to access the KV binding directly.
9+
* Do NOT import it at config time — use `kvCache()` from
10+
* `@emdash-cms/cloudflare` in `astro.config.mjs` instead.
11+
*
12+
* Wire it up:
13+
*
14+
* ```ts
15+
* import { kvCache } from "@emdash-cms/cloudflare";
16+
* emdash({ objectCache: kvCache({ binding: "CACHE" }) });
17+
* ```
18+
*
19+
* with a matching binding in `wrangler.jsonc`:
20+
*
21+
* ```jsonc
22+
* { "kv_namespaces": [{ "binding": "CACHE", "id": "..." }] }
23+
* ```
24+
*/
25+
26+
import { env } from "cloudflare:workers";
27+
import type { CreateObjectCacheBackendFn, ObjectCacheBackend } from "emdash";
28+
29+
/**
30+
* Workers KV enforces a 60-second floor on `expirationTtl`. Clamp shorter TTLs
31+
* up rather than letting `put` throw — invalidation is epoch-comparison-based
32+
* (stale values are overwritten in place on read), so the TTL is only a
33+
* backstop for never-re-read keys and a slightly longer one is benign.
34+
*/
35+
const KV_MIN_TTL_SECONDS = 60;
36+
37+
/**
38+
* Default ceiling (ms) for a single KV operation. A KV read can stall without
39+
* ever resolving or rejecting — a cold cross-region read, or one queued behind
40+
* the Workers six-simultaneous-connection limit. Left unbounded, that hangs the
41+
* isolate. Racing against a timeout turns a stall into a rejection, which the
42+
* object-cache read path treats as a benign cache miss.
43+
*/
44+
const DEFAULT_KV_TIMEOUT_MS = 2000;
45+
46+
/**
47+
* Reject `promise` if it hasn't settled within `ms`. A `ms <= 0` disables the
48+
* timeout. The timer is always cleared so it can't keep the isolate alive.
49+
*/
50+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
51+
if (!(ms > 0)) return promise;
52+
let timer: ReturnType<typeof setTimeout>;
53+
const timeout = new Promise<never>((_resolve, reject) => {
54+
timer = setTimeout(() => reject(new Error(`KV ${label} timed out after ${ms}ms`)), ms);
55+
});
56+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
57+
}
58+
59+
export const createObjectCache: CreateObjectCacheBackendFn = (config): ObjectCacheBackend => {
60+
const binding = typeof config.binding === "string" ? config.binding : "";
61+
if (!binding) {
62+
throw new Error("KV object-cache requires a `binding` name in its config.");
63+
}
64+
65+
// `env` from cloudflare:workers has no index signature.
66+
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- KVNamespace binding accessed from untyped env object
67+
const kv = (env as Record<string, unknown>)[binding] as KVNamespace | undefined;
68+
if (!kv) {
69+
throw new Error(
70+
`KV binding "${binding}" not found. Add it to wrangler.jsonc:\n\n` +
71+
`{\n "kv_namespaces": [{ "binding": "${binding}", "id": "<namespace-id>" }]\n}\n\n` +
72+
`and ensure you're running on Cloudflare Workers.`,
73+
);
74+
}
75+
76+
const timeout =
77+
typeof config.timeout === "number" && config.timeout >= 0
78+
? config.timeout
79+
: DEFAULT_KV_TIMEOUT_MS;
80+
81+
return {
82+
async get(key: string): Promise<string | null> {
83+
return (await withTimeout(kv.get(key, "text"), timeout, "get")) ?? null;
84+
},
85+
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
86+
const put =
87+
ttlSeconds && ttlSeconds > 0
88+
? kv.put(key, value, {
89+
expirationTtl: Math.max(KV_MIN_TTL_SECONDS, Math.floor(ttlSeconds)),
90+
})
91+
: // No TTL: persistent key (used for epoch anchors).
92+
kv.put(key, value);
93+
await withTimeout(put, timeout, "put");
94+
},
95+
async delete(key: string): Promise<void> {
96+
await withTimeout(kv.delete(key), timeout, "delete");
97+
},
98+
};
99+
};

0 commit comments

Comments
 (0)