@happyvertical/smrt-assets

Provider-agnostic asset management with versioning, derivatives, an AssetRuntime + serveAsset() public surface, and a Svelte UI registry.

v0.29.34AssetRuntimeserveAsset()STI AssetUI Registry

Overview

@happyvertical/smrt-assets is the canonical asset layer for SMRT apps. It models Asset as an STI base with versioning, hierarchical derivatives, lookup tables, a generic AssetAssociation for provenance, and a Folder subclass for organisation. v0.24 promotes two new public surfaces: AssetRuntime for I/O and serveAsset() for HTTP delivery.

Key Features

  • STI Asset: base with Folder subclass; cross-package STI (Image extends Asset)
  • Versioning: sequential via primaryVersionId chain + version number
  • Derivatives: source/derived via sourceAssetId (renamed from parentId) for thumbnails, crops, format conversions
  • AssetRuntime: bundles collection, association collection, and store with helpers for source/derived writes and extraction status
  • serveAsset(): standard Web Response for SvelteKit, Hono, and any Node 18+ runtime
  • ASSET_ROLES vocabulary so cross-package tooling agrees on meaning
  • Generic associations: AssetAssociation for provenance / non-owner links (image derivation, fact proofs)
  • Ownership rule: base/domain-owned assets live on dedicated joins (content_assets, profile_assets, event_assets, place_assets, product_assets)
  • UI registry: discoverable Svelte slots via @happyvertical/smrt-assets/ui

Architecture

+---------------------------------------------+
|             Asset Public Surface            |
+---------------------------------------------+
|  Models                                      |
|  - Asset (STI base) + Folder subclass        |
|  - AssetType / AssetStatus (lookups)         |
|  - AssetMetafield (validation)               |
|  - AssetAssociation (generic/provenance)     |
+---------------------------------------------+
|  AssetRuntime  (createAssetRuntime)          |
|  - storeSourceAsset                          |
|  - storeDerivedAsset (sourceAssetId + role)  |
|  - linkDerivation                            |
|  - setExtractionStatus                       |
+---------------------------------------------+
|  Serving                                     |
|  - serveAsset(options) -> Web Response       |
|  - resolveAssetForServing(...) -> bytes      |
|  - remoteMode: proxy | redirect | error      |
+---------------------------------------------+
|  Ownership Joins (in consumer packages)      |
|  - content_assets, profile_assets, etc.      |
|  - asset_associations: generic/provenance    |
+---------------------------------------------+
			

Installation

bash
pnpm add @happyvertical/smrt-assets
bash
npm install @happyvertical/smrt-assets

Imports

typescript
import {
  Asset, AssetCollection,
  AssetAssociation, AssetAssociationCollection,
  AssetType, AssetStatus, AssetMetafield,
  Folder, FolderCollection,
  AssetStore,
  createAssetRuntime,
  serveAsset, resolveAssetForServing, AssetServeError,
  ASSET_ROLES, ASSET_METADATA_KEYS, ASSET_EXTRACTION_STATUS,
} from '@happyvertical/smrt-assets';

AssetRuntime — the public runtime surface

Apps and agents should depend on createAssetRuntime instead of hand-wiring an AssetCollection and an AssetStore per-package (#1128). The runtime bundles collection, association collection, and an initialised store, and offers four high-level helpers.

typescript
import { createAssetRuntime, ASSET_ROLES } from '@happyvertical/smrt-assets';

const runtime = await createAssetRuntime({
  db: { type: 'sqlite', url: './data.db' },
  storage: { type: 's3', bucket: 'my-bucket', region: 'us-east-1' },
});

// 1) Store an upstream document as a source asset
const sourceDoc = await runtime.storeSourceAsset(
  'agenda.pdf',
  pdfBytes,
  { mimeType: 'application/pdf', typeSlug: 'document' },
);

// 2) Store a derivative -- automatically sets sourceAssetId + a provenance link
const pageImage = await runtime.storeDerivedAsset(
  sourceDoc,
  'agenda-page-1.png',
  pngBytes,
  { mimeType: 'image/png', role: ASSET_ROLES.DOCUMENT_IMAGE },
);

// 3) Record provenance only (no byte write)
await runtime.linkDerivation(sourceDoc, pageImage, {
  role: ASSET_ROLES.DERIVATION_SOURCE,
});

// 4) Write canonical extraction metadata into description JSON
await runtime.setExtractionStatus(sourceDoc, 'succeeded', {
  extractedAt: new Date(),
});

setExtractionStatus is non-destructive: if description is free-form prose or non-object JSON, the original value is preserved under the reserved text key. Existing JSON objects are merged into.

Agents that need asset I/O should accept an AssetRuntimeLike in their options rather than asking callers to pass a store + collection separately.

Serving contract — serveAsset()

serveAsset returns a standard Web Response with consistent status codes for missing assets, tenant mismatches, access denials, remote-origin failures, and store read errors. It works in SvelteKit +server.ts, Hono, and any Node 18+ runtime with a global Response. For older runtimes pass responseCtor.

typescript
// SvelteKit +server.ts
import { serveAsset } from '@happyvertical/smrt-assets';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ params, locals }) => {
  const asset = await locals.runtime.collection.get(params.id);
  return serveAsset({
    runtime: locals.runtime,
    asset,
    tenantId: locals.tenantId,
    canAccess: (a) => locals.user.canRead(a),
    disposition: 'inline',
    remoteMode: 'error', // default; opt into 'proxy' | 'redirect' for remote sourceUri
  });
};

Status semantics

CodeTrigger
200Bytes returned; Content-Type, Content-Length, Content-Disposition set
302remoteMode: 'redirect' and sourceUri is http(s)
403Tenant mismatch or canAccess() denies
404Asset id doesn't resolve
500Store read error or unexpected failure (generic body; details logged)
502remoteMode: 'proxy' and origin fetch failed

Remote assets

Assets whose sourceUri is http(s) are supported via remoteMode: 'error' (default) treats a remote URI as an error to avoid SSRF, 'proxy' fetches and returns bytes (with SSRF guards), and 'redirect' issues a 302 to the origin. Pass fetchImpl to swap the fetch client.

Header sanitisation

Content-Disposition filenames are sanitised (control characters stripped; ", \, / replaced) so untrusted asset names cannot inject headers. The 500 body is a generic 'Internal error serving asset'; underlying errors are logged via console.error so operators can debug without leaking paths/bucket names to clients.

Bring-your-own response shell

typescript
import { resolveAssetForServing, AssetServeError } from '@happyvertical/smrt-assets';

try {
  const { asset, data, contentType, filename, size } = await resolveAssetForServing({
    runtime,
    asset,
    tenantId,
  });
  // Build the framework's native response yourself.
} catch (err) {
  if (err instanceof AssetServeError) {
    return makeResponseFor(err.status);
  }
  throw err;
}

Source / derived vocabulary

Roles, metadata keys, and extraction status values are exported as named constants so cross-package tooling (serving, UI pickers, agents) agrees on meaning. Prefer them over ad-hoc strings.

ASSET_ROLES

RoleUse
source_documentOriginal upstream document (agenda PDF, minutes)
document_imagePage/extracted image derived from a source document
thumbnailPreview rendition of another asset
asset_variantDeterministic sized rendition of another asset
proofNon-canonical evidence asset backing a fact
derivation_source"Came from" link on generated media
attachmentGeneric owner-join link
heroPrimary/featured asset on an owner

ASSET_METADATA_KEYS

Canonical keys read/written by extractors and serving helpers:

  • extractionStatus, extractionError, extractedAt
  • sourceUrl, sourceHash
  • pageNumber

ASSET_EXTRACTION_STATUS

Lifecycle values: pending, running, succeeded, failed.

Quick Start

1. Create lookup records

typescript
const imageType = new AssetType({ slug: 'image', name: 'Image' });
await imageType.save();

const published = new AssetStatus({ slug: 'published', name: 'Published' });
await published.save();

2. Create a source asset

typescript
const photo = new Asset({
  name: 'Product Photo',
  slug: 'product-photo-001',
  sourceUri: 's3://mybucket/products/photo.jpg',
  mimeType: 'image/jpeg',
  typeSlug: 'image',
  statusSlug: 'published',
  version: 1,
});
await photo.save();

3. Version 2 — chain via primaryVersionId

typescript
const v2 = new Asset({
  name: 'Product Photo',
  slug: 'product-photo-002',
  version: 2,
  primaryVersionId: photo.id,
  sourceUri: 's3://mybucket/products/photo-v2.jpg',
  mimeType: 'image/jpeg',
  typeSlug: 'image',
  statusSlug: 'published',
});
await v2.save();

4. Derivative via sourceAssetId (or runtime helper)

typescript
// Manual form
const thumb = new Asset({
  name: 'Thumbnail',
  slug: 'product-photo-001-thumb',
  sourceAssetId: photo.id,
  sourceUri: 's3://mybucket/products/photo-001-thumb.jpg',
  mimeType: 'image/jpeg',
  typeSlug: 'image',
  statusSlug: 'published',
});
await thumb.save();

// Preferred: runtime helper writes bytes + sets sourceAssetId + records provenance
const thumb2 = await runtime.storeDerivedAsset(photo, 'thumb.jpg', thumbBytes, {
  mimeType: 'image/jpeg',
  role: ASSET_ROLES.THUMBNAIL,
});

5. Generic association (for provenance / non-owner links)

typescript
// AssetAssociation is for generic/provenance links only.
// Owner-side noun joins (content_assets, profile_assets, ...) belong in the owning package.
const assoc = new AssetAssociation({
  assetId: derivedImage.id,
  metaType: '@happyvertical/smrt-assets:Asset',
  metaId: sourceDoc.id,
  role: 'derivation_source',
  sortOrder: 0,
});
await assoc.save();

Ownership rule (and what AssetAssociation is not)

Base/domain-owned asset relationships belong on dedicated noun join tables in the owning package: content_assets, profile_assets, event_assets, place_assets, product_assets. Those tables are typed, indexed, and tenanted by the consumer.

AssetAssociation remains for generic/provenance links — e.g. image derivation chains, fact proofs, "came-from" relationships — where there is no natural owner table.

Versioning and derivatives

Sequential versions

typescript
// v1 is its own primary version
const v1 = new Asset({
  name: 'Photo', slug: 'photo-v1',
  sourceUri: 'v1.jpg', mimeType: 'image/jpeg',
  typeSlug: 'image', statusSlug: 'published', version: 1,
});
await v1.save();

// v2 chains via primaryVersionId = v1.id
const v2 = new Asset({
  name: 'Photo', slug: 'photo-v2',
  sourceUri: 'v2.jpg', mimeType: 'image/jpeg',
  typeSlug: 'image', statusSlug: 'published',
  version: 2, primaryVersionId: v1.id,
});
await v2.save();

// listVersions() walks the chain (by primary version id)
const history = await collection.listVersions(v1.id);

Derivatives (source/derived)

Parallel processing variants — thumbnails, format conversions, crops — keyed by sourceAssetId (renamed from parentId):

typescript
const derivatives = await original.getDerivatives(); // was getChildren()
const source = await thumbnail.getSource();          // was getParent()

Metadata fields

AssetMetafield declares custom metadata definitions with JSON validation rules so values stay typed and bounded.

typescript
const widthField = new AssetMetafield({
  slug: 'width',
  name: 'Width',
  validation: JSON.stringify({ type: 'integer', minimum: 0, maximum: 10000 }),
});

// Example validation shapes
// { type: 'integer', minimum: 0, maximum: 10000 }
// { type: 'string', enum: ['portrait', 'landscape', 'square'] }
// { type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' }

Canonical extraction metadata

Extractors should write the keys defined by ASSET_METADATA_KEYS through runtime.setExtractionStatus() so serving + UI tooling can rely on consistent names.

UI Registry

The ./ui subpath exports ASSETS_MODULE_META and ASSETS_UI_SLOTS so registry-driven hosts (smrt-chat, dynamic admin shells) can discover the package's Svelte components without a hard import. Side-effect-importing @happyvertical/smrt-assets/svelte registers them with ModuleUIRegistry.

typescript
import { ASSETS_MODULE_META, ASSETS_UI_SLOTS } from '@happyvertical/smrt-assets/ui';
import '@happyvertical/smrt-assets/svelte'; // side-effect: ModuleUIRegistry.register(...)
import { ModuleUIRegistry } from '@happyvertical/smrt-svelte/registry';

const AssetManager = ModuleUIRegistry.get('@happyvertical/smrt-assets', 'asset-manager');

Registered slots

Slot IDPurpose
asset-managerAdmin shell — full CRUD surface
asset-gridList view: card grid
asset-listList view: table rows
asset-detailDetail/preview view
asset-toolbarAction surface above list views
asset-action-barSelection-aware bulk actions
asset-create-modalUpload/create form

Slot IDs are stable contracts — host apps reference them by string.

Tags (raw join)

Tag wiring is implemented via raw db.upsert() on the asset_tags join table — not via an SMRT model. The helpers below cover the day-to-day flow.

typescript
await assets.addTag(asset.id, 'category/products/shoes');
await assets.addTag(asset.id, 'featured/homepage');

const isFeatured = await asset.hasTag('featured/homepage');
const tags = await asset.getTags();
const featured = await assets.getByTag('featured/homepage');

Integration with other modules

smrt-images (cross-package STI)

Image in @happyvertical/smrt-images extends Asset and shares the assets table via _meta_type='Image'. Image derivation uses AssetAssociation for provenance (role derivation_source).

smrt-content

Content uses the dedicated content_assets noun join — owner-side attachments, heroes, thumbnails — not AssetAssociation. Both ends agree on role names via ASSET_ROLES.

smrt-video / smrt-voice

Domain noun joins (character_assets, performer_assets, scene_assets) own their links; video shots inherit content_assets through their Content base.

smrt-facts

Facts attach evidence via AssetAssociation with role proof.

API Reference

Runtime + serving exports

ExportDescription
createAssetRuntime(options)Returns an AssetRuntime
AssetRuntimeBundles collection, association collection, store
storeSourceAsset(name, data, opts)Create a source asset (record + bytes)
storeDerivedAsset(source, name, data, opts)Create a derivative with parentId + provenance link
linkDerivation(source, derivative, opts)Record provenance without writing bytes
setExtractionStatus(asset, status, opts?)Write canonical extraction metadata into description JSON
serveAsset(options)Web Response with full status semantics
resolveAssetForServing(options)Resolve asset bytes for custom response shells
AssetServeErrorThrown by resolver on the same failure modes

Models

ExportDescription
AssetSTI base — versioning, derivatives, lookup FKs
FolderSTI subclass for hierarchical organisation
AssetAssociationGeneric/provenance join: assetId + metaType + metaId + role + sortOrder
AssetType / AssetStatusClassification + lifecycle lookups
AssetMetafieldCustom metadata definitions with JSON validation

Vocab exports

ExportDescription
ASSET_ROLESCanonical role names (source_document, document_image, thumbnail, asset_variant, proof, derivation_source, attachment, hero)
ASSET_METADATA_KEYSCanonical metadata field names
ASSET_EXTRACTION_STATUSpending | running | succeeded | failed
ASSETS_MODULE_META / ASSETS_UI_SLOTSFrom ./ui: registry-driven UI discovery