Back to blog
April 8, 2026·8 min·Nima Nejat

Building a package registry on Cloudflare D1 and Workers

How we built registry.axint.ai: serverless SQLite, Durable Objects for publish atomicity, and aggressive edge caching.

cloudflared1workersregistryarchitecture

We needed a package registry for shareable intent definitions. The constraints: globally fast, cheap to run, and simple enough that two people can maintain it.

We built it on Cloudflare D1 (serverless SQLite at the edge) and Workers. No managed database. No servers. The monthly bill at 10k packages and 50k downloads is under $50.

The schema

Intent packages are immutable. You publish a version once; it never changes. This simplifies everything — aggressive caching, no invalidation logic, no conflict resolution.

```sql CREATE TABLE packages ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, owner_id TEXT NOT NULL, description TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );

CREATE TABLE versions ( id INTEGER PRIMARY KEY, package_id INTEGER NOT NULL REFERENCES packages(id), version TEXT NOT NULL, ir BLOB NOT NULL, checksum TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(package_id, version) ); ```

Packages are published as [IR blobs](/blog/intermediate-representation-design), not source code. This means a TypeScript-authored package can be consumed from Python and vice versa.

Publish atomicity

Publishing uses a Durable Object to ensure only one request for a given package/version pair succeeds. No locking. No distributed transactions. The Durable Object provides single-writer semantics natively.

```typescript export class PublishGate implements DurableObject { async fetch(request: Request) { const { name, version, ir, signature } = await request.json();

const verified = await verifySignature( signature, JSON.stringify({ name, version, ir }) ); if (!verified) return new Response("Invalid signature", { status: 401 });

const existing = await db.query( "SELECT id FROM versions WHERE package_id = ? AND version = ?", [await getPackageId(name), version] ); if (existing.length > 0) { return new Response("Version exists", { status: 409 }); }

await db.prepare( "INSERT INTO versions (package_id, version, ir, checksum) VALUES (?, ?, ?, ?)" ).bind(await getPackageId(name), version, ir, computeChecksum(ir)).run();

return new Response(JSON.stringify({ published: true }), { status: 201 }); } } ```

Fetch caching

Fetching a package version is a D1 query cached at the edge. Since versions are immutable, we cache with max-age=31536000, immutable. A version URL never changes, so browsers and CDN nodes cache for a year.

typescript const response = new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json", "Cache-Control": "public, immutable, max-age=31536000", }, }); await env.CACHE.put(request, response.clone());

First request hits D1. Every subsequent request from that region is served from the edge. Sub-5ms globally after the first fetch.

Why not Postgres?

Postgres is simpler for us as developers. D1 is simpler for our users. No managed database means no latency variance by region. Queries execute at the nearest edge node. And for a registry of immutable blobs, SQLite's ACID semantics are more than sufficient.

If we needed cross-region writes or complex joins, we'd reconsider. For append-only package data with aggressive caching, D1 is the right tool.