Move a Deno KV app from Deno Deploy to Cloudflare Workers with its data and its KV semantics intact, and check that nothing was lost.
- A
Deno.Kv-compatible client on a SQLite Durable Object. It gives you tuple-key order identical to Deno KV, versionstamps, atomiccheck/set/delete/sum/min/max,expireIn, andlistwith prefix/start/end/reverse/limit/cursor. Most app code runs unchanged through aDeno.openKv()shim. - A TTL-preserving export and import. Versionstamps are kept, large inputs are batched, and a per-prefix TTL policy applies where the source cannot tell you expiry.
- A static
expireInscanner that drafts that TTL policy from your own code. - A verify harness. It replays one request script against the Deno app and the Worker, then diffs the responses and the stored KV state.
Free and MIT-licensed. Status: 0.1.2. Tested locally against wrangler dev (workerd), not yet against a deployed Cloudflare account. See
Differences and limits.
Workers KV has no atomic check, no versionstamps, no ordered tuple keys and eventually consistent reads. The usual port is a get-then-put, and these are the failures we measured:
| What we ran | Deno KV | Naive port |
|---|---|---|
| 20 concurrent redemptions of one single-use link, real public app ported to Workers KV | 1 redirect, 19 × 410 | 20 redirects |
| Redemption racing a delete, same app | link stays deleted | deleted link comes back as a stored record |
Example app in this repo, check() dropped (test/naive-worker), tools/verify.ts |
1 redemption | up to 20 redemptions; verify flagged 40/40 runs |
The data copy has its own trap. The Deno KV API never exposes an entry's expiry, so an export made with @deno/kv-utils (the documented
route) turns every expiring key into a permanent one. In a real view-counter app ported this way, a visitor who hit its 1-hour rate
limit stayed rate-limited forever and was never counted again.
A single Durable Object serializes commits the way check() expects, and SQLite keeps Deno's key order. This kit is that port, done once
and tested against real Deno KV.
Requirements: a Worker with Durable Objects on SQLite storage (available on the Workers Free plan), wrangler 4. You also need Deno 2.x to run the tools.
npm install github:CyberNative-AI/deno-kv-workers#v0.1.2The tools in steps 3–6 run with Deno from a pinned URL, because Deno does not run TypeScript from node_modules. Set this once, with the
same release as the install (a clone of this repo works too: use its tools directory):
T=https://raw.githubusercontent.com/CyberNative-AI/deno-kv-workers/v0.1.2/toolsMigration map: export → import → verify
Stop source writes, export with its recheck, then import in batches into a SQLite Durable Object.
Exact expiry is retained when available; otherwise review the TTL policy, which counts from import time.
Replay the same request script against both apps and compare replies and KV state before switching traffic.
Run verification on copies. Tested locally with wrangler dev (workerd); deployed Workers and hosted
Deno Deploy export remain untested.
1. Bind the Durable Object in wrangler.jsonc:
2. Keep your app code. Import the shim first, and export the class:
import "deno-kv-workers/shim"; // installs Deno.openKv() and Deno.KvU64
import { DenoKvObject, handleMigration } from "deno-kv-workers";
import { handle } from "./app.ts"; // your existing handler, unchanged
export { DenoKvObject };
export default {
async fetch(req: Request, env: Record<string, unknown>) {
return (await handleMigration(req, env)) ?? handle(req, await Deno.openKv());
},
};A module-level const kv = await Deno.openKv() from the Deno version can stay. Remove the Deno.serve(...) call and export your handler
instead, as above. The handle works across requests because it creates a fresh Durable Object stub for each call (Workers reject a stub
shared between requests). You can also skip the shim: import { openKv } from "deno-kv-workers" and call openKv(env.DENO_KV); at module
scope, take env from import { env } from "cloudflare:workers".
examples/single-use-links is a complete app that runs on both platforms.
3. Draft a TTL policy from your code:
deno run --allow-read --allow-write $T/scan.ts ./my-app --policy-out ttl-policy.jsonOutput on the test fixture (test/fixtures/scan-app/main.ts):
expireIn writes: 6
main.ts:7 key [RL_PREFIX, ip] prefix ["ratelimit"] expireIn HOUR = 3600000 ms
main.ts:8 key ["uniq", ip] prefix ["uniq"] expireIn 24 * HOUR = 86400000 ms
main.ts:10 key ["daily", day, ip] prefix ["daily"] expireIn 7 * 24 * 3600_000 = 604800000 ms
main.ts:12 key ["sessions", sid] prefix ["sessions"] expireIn SESSION_TTL = 604800000 ms
main.ts:13 key ["daily", "summary"] prefix ["daily","summary"] expireIn 30 * 24 * HOUR = 2592000000 ms
main.ts:15 key key prefix ["dynamic"] expireIn ttl = ? ms
draft policy (5 rules):
["ratelimit"] expireIn 3600000 ms (main.ts:7)
...
NEEDS REVIEW (1): prefix or duration not resolvable statically
main.ts:15 key key expireIn ttl
The scan is static and best-effort. It resolves literal key prefixes, constant arithmetic and same-file const names. If one prefix has
several windows, the scan keeps the longest, so no key expires earlier than it would on Deno. A key whose parts are all literals, such as
["daily", "summary"], gets a rule with "exact": true that covers only that key; every other rule covers the keys below its prefix, as
list({ prefix }) does. Review the draft before you use it.
4. Export the Deno KV data. From a SQLite file (local Deno or self-hosted denokv):
deno run --unstable-kv -A $T/export.ts path/to/kv.sqlite --out export.ndjsonFrom a database hosted on Deno Deploy, pass its KV Connect URL and an access token, as Deno documents in
Connect from outside Deno Deploy. The Databases table on
the database instance page in the Deno Deploy console shows each database's ID. Every timeline of an app has its own database; the
production one is named <app-id>-production. A personal or an organization access token works.
DENO_KV_ACCESS_TOKEN=ddo_... deno run --unstable-kv -A $T/export.ts \
https://api.deno.com/v2/databases/<database-id>/connect --out export.ndjsonA hosted export carries no expiry times: KV Connect does not return them, so every expiring key gets its window from the TTL policy in step 3. Review
that policy before step 5. KV data left on Deno Deploy Classic cannot be read this way: Deno's
migration guide says to contact support@deno.com. Ask them for a @deno/kv-utils NDJSON
export; step 5 imports that file.
The output is @deno/kv-utils NDJSON. If the source is a SQLite file (local Deno or self-hosted denokv), each expiring entry also gets
its exact expireAt, read from the file. An existing kv-utils export also works as input.
Stop writes to the source first. Deno KV has no snapshot read, so a list is read page by page, and an export taken while the app
still writes can hold a state that never existed: in our test, an export taken during atomic transfers lost one or two units from a
balance total that every commit kept constant. The export therefore lists the store a second time and compares every key,
versionstamp and expiry. If anything changed, it exits with code 3 and writes nothing. Put the app in maintenance mode (or make it
read-only) and run it again. Keys that expire during the export also count as changes. For a local file, stopping writes also protects
the app: the export opens the file with Deno.openKv, and in our test that made occasional concurrent commits of the app fail with
database is locked.
The export is written to a temporary file next to --out and replaces --out only when it is complete and checked, so a
failed, refused or interrupted (Ctrl-C) run leaves an earlier export there unchanged. Without --out the export goes to stdout, which
needs --no-recheck: lines already printed cannot be taken back.
--no-recheck skips the second pass, and with it both guarantees: the copy can hold a state that never existed, and a key written
during the export can carry a stale or missing expireAt. --prefix '["users"]' exports one prefix; the check then covers only that
prefix, so the copy is consistent only inside it, and separate exports of different prefixes are not consistent with each other.
5. Import it into the Worker, through the migration route handleMigration exposes:
wrangler secret put KV_MIGRATION_TOKEN # temporary; delete it after the migration
KV_MIGRATION_TOKEN=... deno run -A $T/import.ts --url https://my-app.example.workers.dev \
--in export.ndjson --policy ttl-policy.jsonThe import preserves versionstamps, so new writes sort after the imported ones. It sends batches of up to 1 MB / 5,000 entries
(--batch-bytes, --batch-entries, --dry-run) and skips entries that have already expired. On the Workers Free plan, add
--batch-entries 100. Free Workers get 10 ms of CPU per request; in our local measurement a 5,000-entry batch needed about 30 ms in the
Worker alone, and about 0.06 ms per entry in total. A batch that fails can be sent again: the rerun writes the same entries. An entry with
an exact expireAt keeps it. Otherwise the longest matching policy prefix applies, counted from import time. The route answers 404 unless
the token is set and matches.
6. Verify before you switch traffic. Run the same request script against the Deno app and the Worker:
KV_MIGRATION_TOKEN=... deno run --unstable-kv -A $T/verify.ts script.json \
--a http://127.0.0.1:8000 --b http://127.0.0.1:8787 --state-a-kv kv.sqlite --state-b workerresponses: 22/22 steps identical after normalization
state: 10 vs 10 entries; 0 differing entries
note: 8 concurrent steps are samples; a clean result covers only the races hit
RESULT: no differences
A script is a list of HTTP steps. Each step can run concurrently ("concurrent": 20), save a response field for later steps, or sleep. A
concurrent step is one sample of a race, and a race can happen to serialize: on the check-dropping control, 1 in 10 of our 20-way races
showed a single winner, exactly as a correct port does. Race each check-guarded path several times, each on fresh data; the example races 8
links. verify prints a note when a script has concurrent steps. Regex normalize rules mask generated ids and times. See
examples/single-use-links/script.json. The exit code is 1 when anything differs, so the check
also works in CI. Run it on copies of your data, not against production.
sh test/run.sh starts a local Worker under wrangler dev and compares it with real Deno KV (Deno 2.9.7, wrangler 4.149.0):
- Key encoding and order: 1,800 random mixed-type keys (bytes, strings with NUL and non-ASCII characters, ±0, NaN, ±Infinity, bigints up
to 2^200, booleans). The encoded bytes are identical to what Deno KV writes to SQLite, and the sort order is identical to
kv.list(). - Behaviour: four randomized programs of 250 operations each (
set,delete,get,getMany,listwith prefix/start/end/reverse/limit/batchSize/cursor, atomic commits with passing and failing checks,sum/min/max, sums on non-U64 values, invalid selectors). Every result and error message equals Deno's. Versionstamps are compared by order. - Concurrency: 20 concurrent check-guarded commits. Exactly one wins on each platform.
- Module-level handle: one
await Deno.openKv()at module scope serves sequential and 20-way concurrent requests correctly. - TTL: an expired key is hidden at once and deleted by the Durable Object alarm. After Deno's own sweep, the results match Deno's.
- Migration: export, then import in batches. Keys, values, versionstamps and exact expiry survive, new writes sort after imported ones, and a wrong token gets 404.
- Scanner on a fixture with constants, atomic writes, and strings and comments that must be ignored. Its draft policy, applied by the import, gives each fixture key the window its app writes.
- Verify: the example script races 8 fresh links 20 ways each. The example app has no differences on the port, and the check-dropping control is flagged.
- Unsupported calls throw explicit errors.
- Not supported:
kv.watch(), queues (enqueue,listenQueue), andDeno.openKv(path). Each throws an error that names the Cloudflare alternative. Consistency options are accepted and ignored: every read is strongly consistent. - Expiry is stricter than Deno's. Deno can serve an expired key until its background sweep. This port hides it at once.
- Hosted Deno Deploy export is untested. We have not run step 4 against a hosted database. Against a self-hosted
denokv0.14.0 server, over the same KV Connect protocol, it exported the same entries, in the same order, as a direct read of the store's file, and the import gave every expiring key its policy window. Exact expiry is never readable over KV Connect. Use the scanner's TTL policy instead. - One Durable Object holds one KV database. All commits are serialized, which is what makes
check()exact. Throughput is that of one Durable Object, and storage is capped by the Durable Object SQLite limit. An app that needs more must shard by key prefix itself. This kit does not do that for you. - Policy expiry counts from import time, so a key can outlive its original deadline by up to one window. It never expires earlier.
@deno/kv-utilswrites key part-0as0. This kit's export keeps-0, and its import accepts both.- Production Workers behaviour (deployed Durable Objects, real network latency, CPU limits) has not been measured by us. Run
tools/verify.tsagainst a staging Worker before you switch traffic. Cloudflare does not generate preview URLs for a Worker that implements a Durable Object, so deploy a separate one (for examplewrangler deploy --env staging). Unless its binding setsscript_nameto the production Worker, its Durable Objects have their own storage, so the script's writes stay out of production. - A port can still change behaviour outside KV. One app we ported recorded
cf-connecting-ipwhere Deno had storednull. The verify diff surfaced it.
Open an issue if verify shows a difference or your app hits an unsupported API.
MIT, © 2026 CyberNative AI LLC. src/vendor/kv_json.ts is derived from @deno/kv-utils 0.1.4, © the Deno
authors, MIT (notice).
{ "compatibility_date": "2026-10-01", "durable_objects": { "bindings": [{ "name": "DENO_KV", "class_name": "DenoKvObject" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["DenoKvObject"] }] }