Collections & Conversions
Collections are the policy layer — MIME whitelist, single-file replacement, ordering, and a per-collection disk. Conversions are the image pipeline — width/height/fit/format/quality, generated eagerly or lazily and cached.
A collection is a named bucket you attach files to, with a policy attached: which MIME types are allowed, whether it holds one file or many, which disk it lives on, and which image conversions it offers. A conversion is a derived image (a thumbnail, an OG image, a WebP variant) produced from the original. This page covers both.
Collections
You declare collections in config/media.ts. The shape is MediaCollectionConfig:
interface MediaCollectionConfig {
name: string
single?: boolean // attaching replaces any existing media in this collection
disk?: string // target disk (defaults to the storage default disk)
acceptsMimeTypes?: string[] // allowed MIME types; others are rejected on attach
conversions?: ConversionPreset[] // image presets available for this collection
transformers?: Transformer[] // content transformers (HLS, metadata probe, your own)
}conversions and transformers both produce named entries in record.conversions, so within one collection the two lists share a single namespace. A name used twice — by two presets, by two transformers, or by one of each — throws TransformerConflictError when the registry is built, which is at boot rather than on the first attach. See Transformers for what goes in that second list.
defineConfig({
collections: [
{ name: 'avatar', single: true, acceptsMimeTypes: ['image/png', 'image/jpeg'] },
{ name: 'gallery', conversions: [{ name: 'thumb', width: 200, height: 200 }] },
{ name: 'invoices', disk: 'private-s3', acceptsMimeTypes: ['application/pdf'] },
],
})Collections are opt-in constraints
Declaring a collection is how you constrain it. Attaching to a collection you never declared is allowed — it uses a permissive default ({ name }): no MIME whitelist, not single, no conversions, default disk. Declare a collection only when you want a whitelist, single-file behaviour, conversions, or a non-default disk.
MIME whitelist
When acceptsMimeTypes is set, attach checks the incoming mimeType against it before writing anything. A mismatch throws MimeNotAllowedError (code E_MEDIA_MIME_NOT_ALLOWED) — so no orphan file is ever written for a rejected upload.
// collection 'avatar' accepts only png/jpeg
await media.library.attach({
ownerType: 'User', ownerId: '1', collection: 'avatar',
fileName: 'doc.pdf', mimeType: 'application/pdf', contents,
})
// → throws MimeNotAllowedErrorThe check is an exact string match against the mimeType you pass — there's no wildcard expansion, so list each type you accept ('image/png', 'image/jpeg', 'image/webp', …).
The whitelist is authoritative, but a generic declaration isn't a hard stop. If the declared mimeType is a bare top-level type ('text', 'application' — some multipart parsers truncate the header) or simply isn't on the list, attach / attachExisting first try to pin a concrete MIME from the file extension (a small built-in map: .csv → text/csv, .tsv, .txt, .xlsx/.xls, .json, raster images, .svg, .pdf, …). If the extension resolves to a type on the whitelist, that normalized value is what the record stores. If it still resolves to nothing acceptable, the rejection lists what the collection accepts:
// collection 'avatar' accepts only image/png
// → throws MimeNotAllowedError:
// MIME type "text" is not allowed in collection "avatar". Allowed: image/png.The signature check still applies after normalization — normalization only re-derives the declared type, never weakens what content the collection accepts.
The content is checked too, not just the declared type
The mimeType above is written by your app — very often a hardcoded constant, or a Content-Type header the client chose. Checking only that whitelists nothing about the bytes themselves: a user renames payload.png to report.pdf, your controller declares 'application/pdf', and the whitelist waves it through.
So whenever a collection declares acceptsMimeTypes, the library also reads the file's magic-byte signature and validates the real content:
| The signature says… | Result |
|---|---|
| the same type you declared | accepted |
| a different type | rejected — ContentTypeMismatchError (code E_MEDIA_CONTENT_TYPE_MISMATCH) |
| nothing recognisable, and every accepted type is signature-detectable | rejected — ContentSignatureUnrecognizedError (code E_MEDIA_CONTENT_SIGNATURE_UNRECOGNIZED) |
| nothing recognisable, and some accepted type has no signature | falls back to the declared type (already whitelisted) |
// collection 'exams' accepts only application/pdf; `contents` is really a PNG
await media.library.attach({
ownerType: 'Patient', ownerId: '7', collection: 'exams',
fileName: 'scan.pdf', mimeType: 'application/pdf', contents,
})
// → throws ContentTypeMismatchError:
// File contents are actually "image/png" (detected from the file signature), not the
// declared "application/pdf". Collection "exams" accepts [application/pdf].The embedded signature table covers PNG, JPEG, GIF, WEBP, PDF, MP4, QuickTime, WebM, Matroska, AVI and MPEG-TS — no extra dependency. You can call the detector yourself with detectMimeType(head).
For the video containers, detection is signature-precise, not extension-guessing: an MP4 is identified by its ftyp box and major brand (so a QuickTime .mov relabeled video/mp4 is a mismatch, not a pass), and a WebM is told apart from a generic Matroska file by the DocType inside the EBML header. An ftyp brand outside the recognised MP4 set (3GPP, M4A ) and an EBML DocType that is neither webm nor matroska stay unrecognised rather than being guessed — under an open whitelist they fall back to the declared type as before.
Closed whitelists reject unrecognisable content
Formats with no fixed signature (SVG, CSV, plain text, most office formats) are unknown, not invalid — rejecting them outright would break legitimate uploads. But "unknown" is only uninformative when an accepted type could plausibly BE unknown. So the rule turns on the whitelist itself:
- Closed whitelist — every type in
acceptsMimeTypesis in the signature table (['application/pdf'],['image/png', 'image/jpeg'],['video/mp4']). Content matching no signature cannot be any of them, so it is rejected withContentSignatureUnrecognizedError. A.txtrenamed to.pdfno longer becomes a record, and your app does not have to reimplement that check. - Open whitelist — at least one accepted type has no signature (
['application/pdf', 'image/svg+xml'],['text/csv']). Unrecognisable content is the normal case there, so the declared type stands, exactly as before.
// collection 'exams' accepts only application/pdf (closed); `contents` is plain text
// → throws ContentSignatureUnrecognizedError
// collection 'mixed' accepts ['application/pdf', 'image/svg+xml'] (open); an SVG
// → accepted, declared type standsUse isDetectableMimeType(type) / isClosedSignatureWhitelist(types) if you need the same reasoning outside the library.
It never reads the whole file
Only the first SIGNATURE_HEAD_BYTES (189 — two MPEG-TS sync bytes, the deepest probe in the table) are inspected. For attach the head is peeked and then replayed in front of the rest, so a Readable payload stays streaming; for attachExisting it is a short read against the disk that is torn down immediately — adopting a 2 GiB scan in place never downloads it.
Single-file collections
A single: true collection holds exactly one file per owner. On attach, the library first lists the owner's existing media in that collection and deletes it (original + every conversion), then writes the new file. This is the natural shape for an avatar, a cover image, or a logo:
const avatar = await media.library.attach({
ownerType: 'User', ownerId: '1', collection: 'avatar',
fileName: 'me.png', mimeType: 'image/png', contents: v1,
})
const replaced = await media.library.attach({
ownerType: 'User', ownerId: '1', collection: 'avatar',
fileName: 'me2.png', mimeType: 'image/png', contents: v2,
})
// the first file (and its conversions) are gone; only `replaced` remainsOrdering
Every record carries an order (0-based). On attach, the library asks the store for the nextOrder in that owner+collection and assigns it, so files come back in attach order. list() returns records sorted by order ascending:
await media.library.list('Post', '1', 'gallery')
// [{ order: 0, ... }, { order: 1, ... }, { order: 2, ... }]Ordering is append-only — attach always takes the next slot. There's no built-in reorder API; if you need to resequence, update the order field through your own store access. (See the Roadmap.)
Per-collection disk
A collection can pin itself to a specific disk, overriding the top-level disk. A per-attach disk still wins over the collection's. Useful when, say, public images live on a CDN-backed disk and private documents live on a locked-down one:
defineConfig({
disk: 'public-s3',
collections: [
{ name: 'documents', disk: 'private-s3', acceptsMimeTypes: ['application/pdf'] },
],
})Conversions
A conversion is a derived image generated from the original by the configured image processor. You declare presets per collection; each is a ConversionPreset:
interface ConversionPreset {
name: string
width?: number
height?: number
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside'
format?: 'jpeg' | 'png' | 'webp' | 'avif'
quality?: number
eager?: boolean // generate on attach instead of lazily on first access
}defineConfig({
imageProcessor: processors.sharp(),
collections: [
{
name: 'gallery',
conversions: [
{ name: 'thumb', width: 200, height: 200, fit: 'cover' },
{ name: 'card', width: 600, format: 'webp', quality: 80 },
{ name: 'og', width: 1200, height: 630, eager: true },
],
},
],
})What the fields do
These map straight onto sharp:
width/height— target dimensions. With both set, the result is resized to fitfit. With one set, the other is derived to preserve aspect ratio (per sharp's resize rules). With neither, no resize happens — only a format/quality change.fit— how the image fills the box:cover(default),contain,fill,inside,outside. Only applies when at least one dimension is set.format— the output encoding:jpeg,png,webp,avif. Defaults towebpwhen omitted. The actual extension and content-type follow the chosen format.quality— the encoder quality (passed to sharp'stoFormat). Omit for sharp's default.eager— see below.
Eager vs lazy
This is the central conversion behaviour:
- Eager (
eager: true) presets are generated synchronously onattach. By the timeattachresolves, the eager conversions already exist on disk and are recorded. Use eager for conversions you know you'll always need (an OG image, a primary thumbnail). - Lazy (the default) presets are generated on the first
url(id, name)that asks for them, then cached on the record so every subsequent call is a pure lookup. Use lazy for the long tail of sizes you might not always render.
// 'og' is eager → already generated when this resolves
const m = await media.library.attach({ ...input, collection: 'gallery' })
await media.library.url(m.id, 'og') // pure lookup — already on disk
await media.library.url(m.id, 'thumb') // 'thumb' is lazy → generated NOW, then cached
await media.library.url(m.id, 'thumb') // pure lookup the second timeUnder the hood both paths go through ensureConversion(id, name), which is also a public method you can call to force a lazy conversion to exist ahead of time:
await media.library.ensureConversion(m.id, 'thumb') // generate-if-absent, returns the updated recordensureConversion is idempotent — if the conversion already exists it returns the record untouched without re-running sharp.
Where conversion files live
A conversion is written next to its original, under a conversions/ sub-directory, named by the preset and the output format:
Post/1/gallery/<media-id>/photo.jpg ← original
Post/1/gallery/<media-id>/conversions/thumb.webp ← the 'thumb' conversion
Post/1/gallery/<media-id>/conversions/og.webp ← the 'og' conversionThe conversion shares the original's disk, and its path is recorded on the MediaRecord.conversions map (keyed by preset name). Deleting the media removes every conversion file too.
Errors
| Situation | Error | Code |
|---|---|---|
| Asking for a conversion not declared on the collection | ConversionNotDefinedError | E_MEDIA_CONVERSION_NOT_DEFINED |
Asking for any conversion with no imageProcessor configured | ImageProcessorMissingError | E_MEDIA_IMAGE_PROCESSOR_MISSING |
| Asking for a media id that doesn't exist | MediaNotFoundError | E_MEDIA_RECORD_NOT_FOUND |
Conversion presets are image-only — the sharp processor decodes the original as an image. Attaching non-images to a collection is fine (with no conversions), but declaring presets on a non-image collection and then requesting one will fail inside sharp. For everything beyond images — HLS video, metadata probing, and your own derivations — use Transformers, which persist into the same record.conversions map.
The MediaRecord
Every attached file is a MediaRecord. It's what attach, find, and list return:
interface MediaRecord {
id: string
ownerType: string
ownerId: string
collection: string
name: string // display name; defaults to the file name without extension
fileName: string
mimeType: string
size: number // bytes
disk: string // which disk it lives on
path: string
order: number
customProperties: Record<string, unknown> // your own metadata bag, passed on attach
conversions: Record<string, MediaConversion> // keyed by preset / transformer name
createdAt: Date
updatedAt: Date
}You can stash arbitrary metadata on customProperties at attach time (attach({ ..., customProperties: { alt: 'A cat' } })) and read it back off the record.
MediaConversion
One entry in record.conversions. Every field is optional, because the same type covers three quite different results:
interface MediaConversion {
path?: string // entry-point artifact key — absent for a metadata-only transform
disk?: string // disk holding the artifact(s) — absent when there are none
prefix?: string // storage prefix (ends in `/`) holding every artifact of a multi-file result
files?: string[] // every artifact key relative to `prefix`, the entry included
meta?: Record<string, unknown> // transformer-reported metadata, persisted verbatim
}- An image conversion (a
ConversionPreset) fillspath+disk: one file, one key. - A multi-file transformer output — an HLS package — fills
path(the master playlist),prefix(the folder every artifact lives under) andfiles(all of them).filesis the authority both for serving individual artifacts and for deleting the package whole. - A metadata-only transform — a probe, a blurhash — writes no artifact at all:
path,disk,prefixandfilesare all absent and onlymetacarries the result.
Which is why nothing here can be assumed present. Reading record.conversions.probe.path on a probe result gets you undefined, not a key; narrow on the field you actually need.
Next steps
- Attachments — the column-attachment alternative, with the same conversion engine
- Stores & Processors — the sharp processor and the Lucid store behind records
- Testing — assert eager vs lazy behaviour with the fake processor
Configuration
Every key on defineConfig — disk and the disks map, store and stores, imageProcessor, collections, uploads, delivery, the attachment key prefix and the diagnostics toggle — and how the provider builds the MediaManager at boot.
Transformers & HLS
Pluggable content transformations — turn a stored media into derived artifacts (an HLS package, an extracted audio track) or pure metadata (a probe), persisted as named conversions. Ships with a mediabunny-backed HLS transformer and a metadata probe, plus an HLS-aware delivery handler.