chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
Cloudflare shared storage: KV → Durable Object SQLite
Status: shipped 2026-08-05 (Worker version 37116a86). KV dropped entirely
on 2026-08-11 — see below.
What changed
The Cloudflare adapter's shared storage (the SharedStoragePort: sessions,
OAuth tokens/requests, DPoP jtis, app passwords, passkeys) moved out of the
SHARED_KV namespace and into the Durable Object's own SQLite
(state.storage.sql), alongside the repo. Implemented as createSharedStorageSql
in packages/cloudflare/src/index.js, mirroring the Node adapter's schema and
query logic natively (no better-sqlite3 shim).
Why
KV is an eventually-consistent, edge-cached read store (up to ~60s cache TTL per edge). For read-your-writes account state that made:
- the "Where you're signed in" list lag after a login,
- re-login dedup miss on a stale
listSessions, leaving duplicates, and - the DPoP-replay check (
checkAndStoreDpopJti) racy — two near-simultaneous requests could both read "not seen".
The DO's SQLite is strongly consistent and co-located, so reads see their own
writes immediately. This is what Cirrus and the Node adapter already do. The
account's own DID/handle were already in the DO metadata table, so identity was
never affected.
Migration
On first start, migrateSharedKvToSql runs under blockConcurrencyWhile before
the DO serves a request. It copies the durable KV records — actor directory,
OAuth tokens, app passwords, passkeys — into SQLite once, guarded by a
shared_kv_migrated flag in the metadata table. It is best-effort per row and
wrapped so a failure can never abort DO startup (the flag stays unset to retry).
Password sessions are intentionally not migrated — they re-login.
Note: the app-password KV key is apppass:<did>:<name>, and the DID itself
contains colons, so the DID is recovered by stripping the trailing :<name>
(covered by test/kv-migration.test.js).
Cleanup, 2026-08-11
The KV adapter, the one-time migration and its test, and the SHARED_KV binding
in both wrangler.toml files are deleted. test/app-password-storage.test.js
runs its contract suite against createSharedStorageSql on a better-sqlite3
stand-in for the DO SQL interface, in place of the KV adapter.
Two things are left to do by hand:
- Remove the
[[kv_namespaces]]block from the deployment'swrangler.toml(in~/code/pds). Wrangler rejects a binding the code no longer reads only when it is missing, not when it is spare, so this is tidiness. - The
SHARED_KVkeys are untouched and still hold the pre-migration copy. Purge them withwrangler kvonce the SQLite state is known good.
The migration ran and set its shared_kv_migrated flag before this cleanup, so
the live deployment carries the data forward. A Worker on the published 2.1.0
package, which reads KV, starts empty when it upgrades past this point.
Validation
- Cloudflare e2e (
npm run test:e2e:cloudflare) — all auth flows pass on the SQLite path (the 7 failing consent/DPoP tests are pre-existing and identical on the KV baseline; they depend on an external appview unavailable in localwrangler dev). test/kv-migration.test.js— migration key-parsing and one-shot behavior.- Post-deploy:
resolveHandle(reads the migratedactorstable) returned the account DID; identity and health intact.