@happyvertical/smrt-assets
Provider-agnostic asset management with versioning, derivatives, an AssetRuntime + serveAsset() public surface, and a Svelte UI 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
Foldersubclass; cross-package STI (Image extends Asset) - Versioning: sequential via
primaryVersionIdchain +versionnumber - Derivatives: source/derived via
sourceAssetId(renamed fromparentId) for thumbnails, crops, format conversions - AssetRuntime: bundles collection, association collection, and store with helpers for source/derived writes and extraction status
- serveAsset(): standard Web
Responsefor SvelteKit, Hono, and any Node 18+ runtime - ASSET_ROLES vocabulary so cross-package tooling agrees on meaning
- Generic associations:
AssetAssociationfor 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
pnpm add @happyvertical/smrt-assetsnpm install @happyvertical/smrt-assetsImports
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.
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.
// 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
| Code | Trigger |
|---|---|
200 | Bytes returned; Content-Type, Content-Length, Content-Disposition set |
302 | remoteMode: 'redirect' and sourceUri is http(s) |
403 | Tenant mismatch or canAccess() denies |
404 | Asset id doesn't resolve |
500 | Store read error or unexpected failure (generic body; details logged) |
502 | remoteMode: '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
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
| Role | Use |
|---|---|
source_document | Original upstream document (agenda PDF, minutes) |
document_image | Page/extracted image derived from a source document |
thumbnail | Preview rendition of another asset |
asset_variant | Deterministic sized rendition of another asset |
proof | Non-canonical evidence asset backing a fact |
derivation_source | "Came from" link on generated media |
attachment | Generic owner-join link |
hero | Primary/featured asset on an owner |
ASSET_METADATA_KEYS
Canonical keys read/written by extractors and serving helpers:
extractionStatus,extractionError,extractedAtsourceUrl,sourceHashpageNumber
ASSET_EXTRACTION_STATUS
Lifecycle values: pending, running, succeeded, failed.
Quick Start
1. Create lookup records
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
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
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)
// 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)
// 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
// 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):
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.
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.
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 ID | Purpose |
|---|---|
asset-manager | Admin shell — full CRUD surface |
asset-grid | List view: card grid |
asset-list | List view: table rows |
asset-detail | Detail/preview view |
asset-toolbar | Action surface above list views |
asset-action-bar | Selection-aware bulk actions |
asset-create-modal | Upload/create form |
Slot IDs are stable contracts — host apps reference them by string.
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
| Export | Description |
|---|---|
createAssetRuntime(options) | Returns an AssetRuntime |
AssetRuntime | Bundles 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 |
AssetServeError | Thrown by resolver on the same failure modes |
Models
| Export | Description |
|---|---|
Asset | STI base — versioning, derivatives, lookup FKs |
Folder | STI subclass for hierarchical organisation |
AssetAssociation | Generic/provenance join: assetId + metaType + metaId + role + sortOrder |
AssetType / AssetStatus | Classification + lifecycle lookups |
AssetMetafield | Custom metadata definitions with JSON validation |
Vocab exports
| Export | Description |
|---|---|
ASSET_ROLES | Canonical role names (source_document, document_image, thumbnail, asset_variant, proof, derivation_source, attachment, hero) |
ASSET_METADATA_KEYS | Canonical metadata field names |
ASSET_EXTRACTION_STATUS | pending | running | succeeded | failed |
ASSETS_MODULE_META / ASSETS_UI_SLOTS | From ./ui: registry-driven UI discovery |