Skip to content

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 beta never interferes with stable
  • 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 version

File serving

Clients call GET /packages/{id}/channels/stable (or any channel):

json
{
  "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 /health only.
  • 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_upload and can_publish flags set per package. Token must include dev_ 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

Packdog runs on Cloudflare Workers, D1, and R2.