Blob

canonical term: blob | table: hol_blobs This document explains the Blob concept — the storage ledger that connects Cloudflare R2 objects to House of Legends shows, offers, menus, and operator-managed assets.
Doc Status: Current implementation guide | Verified against Hono storage services, Drizzle schema, R2 helpers, generated client calls, seeder uploads, and deployed menu/public asset routes.

Why This Matters

House of Legends stores images, PDFs, and uploaded operational assets in Cloudflare R2. R2 is where the file bytes live; hol_blobs is where the application records what the file is, who created it, how it should be used, and whether it is still active. Without the blob ledger, an R2 key is just a string. With the ledger, operators can list assets, attach them to resources, activate a menu PDF, audit uploads, and eventually clean up unused files safely.

What Is a Blob?

A Blob is the application record for one uploaded R2 object. It does not contain the file bytes. It contains the metadata that makes an R2 object understandable to the product. Every blob records:
  • How the app names it — caller-provided slug
  • Where the file lives — R2 bucket and object key
  • How the app serves it — public API URL in publicUrl
  • What it is — original filename, MIME type, size, purpose
  • Where it came from — operator upload or seeder upload
  • Lifecycle state — active, archived, or a reserved pending state
  • Audit context — creating role, subject, timestamps, and metadata
blob:
  id: "01J..."
  slug: "show-thumbnail-jazz-night"
  bucket: "hol-blobs"
  key: "assets/shows/01J..."
  publicUrl: "https://houseoflegends.vn/api/storage/assets/assets/shows/01J..."
  originalFilename: "cabaret-stage.webp"
  contentType: "image/webp"
  sizeBytes: 284113
  purpose: shows
  source: OPERATOR_UPLOAD
  status: ACTIVE

Blob vs. R2 Object

LayerOwnsExample
R2 objectFile bytes and HTTP metadataR2_BLOBS.get("assets/shows/01J...")
hol_blobsApplication ledger and audit metadatabucket, key, purpose, status, createdByRole
hol_asset_linksHow one blob is used by one resourceshow thumbnail, show gallery item, offer image
API public URLBrowser-readable delivery path/api/storage/assets/:key
R2 is not the source of truth for product meaning. The database is. Code should treat the R2 key as an address and the blob row as the application-owned record.
Do not store raw R2 S3 API URLs in product tables. Store a blob ID or object key, then resolve the browser-readable URL through the backend.

Blob Identity

Blob identity has three separate parts:
FieldMeaningWho controls it
idDatabase primary keyBackend-generated ULID
slugStable application identityCaller-provided, validated by Hono
keyPhysical R2 object addressBackend-generated storage layer
The caller can dictate the slug; it cannot dictate the id. The backend owns the ULID because IDs must stay globally unique and database-safe. The backend also owns the R2 key because object addressing is infrastructure, not product copy. Use slug whenever humans, seed manifests, dashboard workflows, or resource relationships need a stable name:
slug: "show-thumbnail-jazz-night"
slug: "show-gallery-jazz-night-01"
slug: "offer-image-the-celebration-five"
slug: "menu-dinner-show"
Do not parse originalFilename to recover identity. Filenames are user input and operating-system metadata. They can change, contain spaces, include duplicate names, or lose product meaning during export. Store the filename for display and audit only.
If a seed or dashboard upload needs a meaningful name, pass slug explicitly. Do not encode the filename into the R2 key and do not derive the blob’s identity by splitting filename strings.

The Storage Model

The normal model is:
  1. The caller sends file bytes, purpose, and slug.
  2. Hono generates the blob id and storage key.
  3. File bytes are uploaded to R2.
  4. A hol_blobs row tracks the object.
  5. A resource either stores the blob ID directly or links through hol_asset_links.
  6. Read-side services resolve the blob to a public API URL.
  7. The frontend displays the URL and does not know the R2 credentials.

Blob Purposes

purpose is a flat category for browsing and operational grouping.
PurposeTypical use
menuMenu PDFs uploaded from the dashboard or seeder
addonsAdd-on images
offersOffer and menu-item images
showsShow thumbnails and galleries
show-eventsFuture event-specific media
uploadsGeneral operator uploads
seedSeeder-managed assets that do not fit a narrower purpose
purpose should not replace business ownership. If a blob belongs to a show, offer, or another entity, use a direct blob field or an asset link to express that relationship.

Blob Sources

SourceMeaning
OPERATOR_UPLOADThe API received a multipart file and uploaded it with the Worker R2 binding
SEEDThe seeder uploaded an asset through the superuser seed endpoint
The source tells operators how the object entered the system. It is not a permission model by itself.

Blob Status

StatusMeaning
PENDING_UPLOADReserved schema value; current dashboard and seeder uploads should not create this state
ACTIVEThe R2 object exists and the blob can be used
ARCHIVEDThe blob should no longer be used for new links
The direct Worker flow writes bytes to R2 before inserting the active blob row, so the database should not point at an object that was never uploaded. A normal dashboard or seeder upload should land as ACTIVE.

Current Implementation Audit

The storage implementation has one active path: Hono receives the file, writes bytes through the bound R2_BLOBS bucket, and records a blob row.
AreaCurrent state
R2 accessUses the Cloudflare Worker binding env.R2_BLOBS
CredentialsNo R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, or public bucket env is required
Upload APIPOST /api/storage/upload accepts multipart form data and returns the created blob
Generated clientFrontend upload components call generated apiClient.storage.uploadAsset(...)
Public assetsGET /api/storage/assets/* streams objects from R2_BLOBS through the API domain
Menu PDFsGET /api/storage/menu/{bookingPath}/menu.pdf streams the active menu blob for that path
SeederSeeds show, gallery, offer, and active menu assets through the superuser upload endpoint
Menu route shapeBooking-path scoped only
Blob identityUses explicit slug; filenames are not parsed or embedded in R2 keys

Upload Flows

Dashboard surfaces send multipart form data to Hono:
POST /api/storage/upload
contentType: multipart/form-data
fields:
  file: menu.pdf
  purpose: menu
  slug: menu-dinner-show
  bookingPath: DINNER_SHOW
Hono validates the request, generates a unique object key, writes the bytes with env.R2_BLOBS.put(...), and records the blob as ACTIVE. The filename remains available as originalFilename, but the dashboard should use slug for stable identity.

hol_asset_links is the contextual relationship between a blob and a product resource. A single blob row answers “what file is this?” An asset link answers “how is this file being used here?”
FieldPurpose
blobIdThe uploaded file record
resourceTypeLoose resource namespace, such as show or offer
resourceIdResource identifier
rolegallery, thumbnail, avatar, image, attachment, video, or archived
displayOrderOrdering for galleries
isPrimaryPrimary image marker
caption / altTextPer-use presentation metadata
Asset links are intentionally loose references. Adding a new resource type should not require a storage migration.
Use hol_asset_links when the same storage object needs per-resource presentation metadata, ordering, or audit history. Use a direct blob ID only for simple one-to-one fields such as a show thumbnail.

Public URL Resolution

Browser-facing URLs should come from the backend helper that maps a key to the API proxy:
key: "assets/shows/01J..."
publicUrl: "https://houseoflegends.vn/api/storage/assets/assets/shows/01J..."
The public asset route reads the object from the private R2_BLOBS binding and returns the object’s HTTP metadata, ETag, and a long-lived cache header. This keeps R2 credentials and the raw S3-compatible endpoint out of the frontend.
The long-lived immutable cache is only safe for generated blob keys that are never overwritten. slug can be stable while key stays immutable. Stable product routes, such as active menu PDFs, must revalidate.

Menu PDFs use the blob ledger and stream from the active R2_BLOBS object. When an operator activates a menu blob:
  1. Hono verifies the selected blob still exists in R2_BLOBS.
  2. Hono marks other menu blobs for the same bookingPath inactive.
  3. Hono marks the selected menu blob active.
  4. Guest menu routes stream the active PDF from R2_BLOBS by booking path.
The guest route is:
GET /api/storage/menu/{bookingPath}/menu.pdf
If no active blob exists for that booking path, the route returns 404. This is intentional: menu files must be managed through the dashboard blob ledger.

Field Ownership

FieldOwnerNotes
idBackendULID assigned by the service
slugCaller through APIStable application identity; validated by Hono; unique enough for the chosen blob namespace
bucket + keyStorage layerUnique together; point to the R2 object; generated by backend and should not include the original filename
publicUrlBackend URL helperBrowser-readable API proxy URL
originalFilenameUpload boundaryHuman context only; not used as object identity
contentTypeUpload boundary / R2 metadataUsed for serving and display
sizeBytesUpload boundaryDirect uploads record the uploaded file size when the blob row is created
purposeStorage categorizationFlat category, not resource ownership
sourceUpload workflowHow the object entered the system
statusLifecycleControls whether the blob should be used
metadataWorkflow-specific contextMenu uploads store bookingPath here
displayNameMenu managementMenu-specific display label
isActiveMenu managementActive menu selection flag
createdByRole / createdBySubjectAuth boundaryAudit context from the route

Implementation Map

FileResponsibility
apps/hono/src/db/schema/blobs.sql.tshol_blobs Drizzle schema
apps/hono/src/db/schema/asset-links.sql.tshol_asset_links Drizzle schema
apps/hono/src/services/system/blob.service.tsLow-level blob row creation, listing, lookup
apps/hono/src/services/system/asset-management.service.tsDirect Worker upload orchestration and public URL resolution
apps/hono/src/services/system/menu.service.tsMenu blob listing, activation, rename, delete
apps/hono/src/infra/storage/r2.tsObject key creation and backend public asset URL helpers
apps/hono/src/routes/system/storage/queries/get-url.tsStorage API boundary
apps/hono/src/routes/system/storage/public-assets.tsBrowser-readable asset proxy
apps/hono/src/routes/system/storage/menu.tsGuest-facing menu PDF routes
FileResponsibility
apps/frontend/components/dashboard/menu/menu-upload-zone.tsxUploads menu PDFs through the direct Worker upload endpoint
apps/frontend/components/dashboard/menu/menu-list.tsxLists, activates, renames, and deletes menu blobs
apps/frontend/app/[locale]/dashboard/show/[slug]/show-page-client.tsxUploads and links show gallery media
apps/frontend/hooks/dashboard/storage/use-menu-blobs-query.tsReads menu blob data through Fern/TanStack Query
FileResponsibility
apps/seeder/src/seeds/show-image-assets.tsSeeds show thumbnail blobs
apps/seeder/src/seeds/show-gallery-assets.tsSeeds show gallery blobs and asset links
apps/seeder/src/seeds/offer-image-assets.tsSeeds offer image blobs
apps/seeder/src/seeds/menu-assets.tsSeeds menu PDF blobs

Rules Of Thumb

  • Store file bytes in R2.
  • Store file meaning in hol_blobs.
  • Store per-resource usage in hol_asset_links.
  • Use slug for stable application identity.
  • Keep R2 keys backend-generated and filename-free.
  • Store originalFilename; do not parse it, split it, or use it to infer product identity.
  • Keep raw R2 credentials and S3-compatible URLs out of frontend code.
  • Use direct Worker uploads for dashboard-managed files.
  • Do not let UI components invent storage URLs; use backend-resolved URLs.
  • Do not delete blob rows casually. Archive first unless there is an explicit operator delete path.

See Also

  • Offer — offer images are resolved from storage keys or blob references
  • Show — show thumbnails and galleries use R2-backed media
  • Fulfillment — fulfillment presentation may use offer/media assets
  • Environment — deployment and Cloudflare environment context