Architecture
Overview
A package registry for versioned packages (web components, ES modules, or any file bundle). Developers upload packages, test them by version ID, then publish to named channels. Clients always load from a channel.
Stack: Cloudflare Worker (TypeScript) + D1 (SQLite) + R2 (object storage)
Core design
Versions and channels are completely separate.
- Versions — immutable upload artifacts. Each upload creates a new UUID-keyed version. Versions know nothing about channels.
- Channels — named pointers to a version (
stable,beta,v1,v2, anything). Each channel is independent with its own rollback history.
This separation means:
- You can publish the same version to multiple channels simultaneously
- Working on
betanever interferes withstable - Rollback on one channel doesn't affect others
- The system is open for future convenience endpoints (e.g. upload + auto-publish to a channel in one call)
Data model
packages
The catalog of packages. id is a UUID auto-generated at creation — stable forever even if name changes.
package_versions
One row per upload. Truly immutable — never modified after creation. No pointers to other versions.
package_channels
One row per active channel per package. Holds the current version_id and current_history_id (pointer into the rollback chain).
channel_history
A linked list per channel. Each publish creates a new row pointing to the previous one via previous_history_id. Rollback walks this chain backwards — every step lands on a version that was actually published to that channel.
customers
Customer name, hashed API key (blk_ prefix), active flag. Customers can load packages they have been granted access to.
customer_package_access
Which packages each customer can load.
developer_tokens
Developer name, hashed token (dev_ prefix required), active flag. Developers can upload/publish to specific packages.
developer_package_access
Which packages each developer can access and with what permissions (can_upload, can_publish).
publish v1 → stable (history: h1)
publish v2 → stable (history: h1 ← h2)
publish v4 → stable (history: h1 ← h2 ← h4)
rollback stable → v2 (history pointer moves to h2)
rollback stable → v1 (history pointer moves to h1)Workflows
Upload
POST /packages/{id}/upload
→ validate index.js present
→ store files in R2: packages/{id}/versions/{uuid}/...
→ insert package_versions row
→ return versionId (no channel affected)Publish to channel
POST /packages/{id}/channels/{channel} { versionId }
→ insert channel_history row (previous_history_id = current)
→ upsert package_channels (version_id, current_history_id)Rollback
POST /packages/{id}/channels/{channel}/rollback
→ read current_history_id from package_channels
→ follow previous_history_id one step back
→ update package_channels to previous versionFile serving
Clients call GET /packages/{id}/channels/stable (or any channel):
{
"packageId": "uuid",
"channel": "stable",
"versionId": "uuid",
"publishedAt": 1234567890,
"index": "https://pub-xxx.r2.dev/packages/uuid/versions/uuid/index.js",
"baseUri": "https://pub-xxx.r2.dev/packages/uuid/versions/uuid"
}Files are served directly from the R2 public CDN — the Worker only handles metadata. Because every version has a unique UUID, all R2 URLs are immutable and cached forever (Cache-Control: immutable).
index.js is the required entry point. The package uses baseUri internally to load its own assets.
Auth
Four levels:
- Public:
GET /healthonly. - Customer key (
Bearer blk_...):GET /packages/{id}/channels/{channel}— read-only, scoped to packages the customer has been granted access to. Rate limited to 1000 req/min, partitioned by${client-ip}:${token-prefix}so a single IP cannot bypass the limit by rotating tokens. - Developer token (
Bearer dev_...): upload and/or publish to specific packages.can_uploadandcan_publishflags set per package. Token must includedev_prefix — tokens without it are rejected. - Admin: used by the Packdog operator for service management (creating customers, rotating keys, deletion). Not customer-facing — see contact info if you need an admin operation performed.
Customer keys and developer tokens are SHA-256 hashed at rest; only the prefix is shown in listings (blk_a3f8e2c1). The plaintext is shown once at creation and never recoverable — rotation issues a new value.
Design Decisions & Edge Cases
Why no transactions?
setChannel() and deleteVersionFromDb() perform multiple DB operations without explicit transactions. This is intentional:
- D1 batch operations can be added later when needed
- Current approach is simpler and easier to understand
- Failures are rare and don't cause data corruption
- Will add transactions in Tier 1 (when observability is in place to monitor failures)
Why manual deletion order?
deletePackageFromDb() manually deletes in order (history → channels → versions → access → package) instead of using ON DELETE CASCADE. This is intentional:
- Explicit is safer than CASCADE (prevents accidental data loss)
- Clear what happens when you delete
- Easy to add soft delete later if needed
What happens when you delete a version?
deleteVersionFromDb() splices the deleted version out of channel history by setting previous_history_id = NULL. This means:
- Rollback will stop at that point (can't go further back)
- This is safer than re-linking the chain (which could skip important versions)
- Protected versions (last 10 per channel) cannot be deleted
What happens when you publish the same version twice?
Publishing the same version to the same channel creates a new history entry. This is fine:
- It's idempotent (no harm done)
- Rollback will go back to the same version (which is correct)
- History accurately reflects what happened