Blog

Typed KV, and metadata that repairs itself

2026-06-10

An isometric illustration of a cobbler's iron last with a worn boot fitted over it

Someone on your team opens the Cloudflare dashboard to fix a typo in a KV value. They edit the field, save, and close the tab. Nothing errors. A week later a listing that used to filter by status is returning everything, and nobody connects the two.

Workers KV gives you strings and a key. Everything else — what a key means, what shape the value has, what you can learn without reading it — is yours to keep track of, and the usual way of keeping track of it is that everybody remembers.

TypedKv is the wrapper every capability in the kit uses instead. Most of it is ordinary. The last part is about that dashboard edit.

The key is a schema

A store declares a prefix and its key segments as a Zod object, and declared field order is key order:

{
  prefix: "assets",
  key: z.object({ assetType: z.enum([...]), uuid: z.uuid() }),
}
// → assets:photo:3f9c…

Each segment is validated before the key is built, so a malformed identifier fails where you passed it rather than becoming a key that quietly matches nothing. The value gets a schema too, applied on every read and every write. That matters more on the read side than you might expect: the thing you stored six months ago was written by an older version of your code.

The configuration itself is validated at construction, on the principle that config is a boundary and boundaries are checked rather than trusted. It catches the things types cannot: an empty namespace, a separator that collides with the prefix, a key with no segments, a deriveMetadata declared without a metadata schema to put it in.

Metadata is small, and the cap is enforced early

KV metadata is genuinely useful. It rides along with every entry a list returns, so a status flag or a type or a timestamp is available without reading each value — which is the difference between one call and a thousand.

It is also capped at 1024 bytes serialized, and KV enforces that at write time with an error some distance from the code that caused it.

So the cap is enforced at the schema instead:

export function kvMetadata<T extends z.ZodType>(schema: T) {
  return schema.refine((value) => byteLength(JSON.stringify(value)) <= KV_METADATA_MAX_BYTES, {
    message: `KV metadata exceeds ${KV_METADATA_MAX_BYTES} bytes serialized. Keep it tiny — it rides on every list entry.`,
  });
}

A late KV rejection becomes an early validation error, and the message says the thing a reader actually needs to internalize: keep it tiny, because it travels with everything.

The part the dashboard breaks

The Cloudflare dashboard drops metadata when a value is hand-edited.

That is the failure from the top of this post. You open a key to fix one field, save it, and the metadata is gone. The value is fine. Nothing errors. The entry simply stops carrying the flag your list was relying on, and it stops silently — the system keeps working slightly wrong instead of stopping, which is the hardest kind of break to notice.

You cannot prevent it from outside. So TypedKv makes it repairable instead.

deriveMetadata, and self-healing lists

A store may declare a function that computes metadata from the value:

deriveMetadata: (value) => ({ status: value.status, kind: value.kind })

Declaring it changes two things. put computes metadata itself, so callers never pass it and cannot pass something inconsistent with the value. And list self-heals: any entry whose metadata is missing has its value read, its metadata re-derived, and the result written back — value bytes and expiration preserved.

The value becomes the single source of truth, and the metadata becomes a cache of it that repairs itself on the next read.

Three decisions inside the heal

The healing is only worth having if it cannot make things worse, and most of the code is about exactly that.

Nothing empty is ever persisted. If deriving yields nothing, there genuinely is no metadata for that entry, so the write is skipped. Writing empty metadata would satisfy a required schema with a lie.

The write is best-effort. KV requires an expiration at least sixty seconds out, so an entry close to expiry cannot be rewritten. That must not fail the surrounding list, so the healed metadata is returned from memory regardless and a later list retries the write.

The cost is stated, not hidden. Self-healing performs a read and a write per missing entry during a list. That is written into the option’s own documentation, because if you enable it on a large namespace after a bulk external edit, you should know what the first list is going to do.

One bad entry must not break the screen

This is the one I would most want you to take away, because the failure is brutal and the cause is invisible.

Picture a listing screen backed by KV. It works for months. Then somebody adds a required field to the metadata schema, or hand-edits an entry, or a write lands half-formed. Now one entry out of ten thousand has metadata that does not parse.

If your list code parses metadata as it goes and lets the error propagate, here is what happens. Every list call fails. Not the one entry — all of them, including the 9,999 that are perfectly fine. The screen stops rendering. Pagination stops working. And the error you get names a parse failure, not a key, so nothing in it tells you which of ten thousand entries is the problem. You are now writing a script to walk the namespace by hand to find the one bad row, on an outage.

One entry’s metadata took down an entire feature, and left you no thread to pull.

So list resolves each entry’s metadata inside its own try, and any failure degrades that entry to null metadata while still returning the key:

try {
  // parse the stored metadata, or rebuild it from the value
} catch {
  return null;
}

The blast radius is one entry, and the entry is still in your results. Your screen renders. The bad row is visible in the list with no metadata attached, which is the thread you needed. And because the rebuild path — the read of the value — is inside that same try, an entry whose value has gone bad cannot take the listing down either.

If deriveMetadata is configured, there is a decent chance the next list repairs it before you get round to looking.

Why it belongs in core

None of this is exotic, which is the argument for it living in one place. Every capability that stores anything in KV wants the same things: keys you cannot typo, values that still parse after a schema change, metadata small enough to ride on a list, an answer for what happens when somebody edits an entry by hand in a browser, and a listing that survives one bad row.

Written once, that is a few hundred lines. Written per capability, it is a few hundred lines with eighteen slightly different opinions about missing metadata — and seventeen of them will not have thought about the dashboard at all.