Agora

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.

config/media.ts
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 MimeNotAllowedError

The 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: .csvtext/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 declaredaccepted
a different typerejectedContentTypeMismatchError (code E_MEDIA_CONTENT_TYPE_MISMATCH)
nothing recognisable, and every accepted type is signature-detectablerejectedContentSignatureUnrecognizedError (code E_MEDIA_CONTENT_SIGNATURE_UNRECOGNIZED)
nothing recognisable, and some accepted type has no signaturefalls 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 acceptsMimeTypes is 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 with ContentSignatureUnrecognizedError. A .txt renamed to .pdf no 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 stands

Use 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` remains

Ordering

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
}
config/media.ts
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 fit fit. 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 to webp when omitted. The actual extension and content-type follow the chosen format.
  • quality — the encoder quality (passed to sharp's toFormat). Omit for sharp's default.
  • eager — see below.

Eager vs lazy

This is the central conversion behaviour:

  • Eager (eager: true) presets are generated synchronously on attach. By the time attach resolves, 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 time

Under 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 record

ensureConversion 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' conversion

The 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

SituationErrorCode
Asking for a conversion not declared on the collectionConversionNotDefinedErrorE_MEDIA_CONVERSION_NOT_DEFINED
Asking for any conversion with no imageProcessor configuredImageProcessorMissingErrorE_MEDIA_IMAGE_PROCESSOR_MISSING
Asking for a media id that doesn't existMediaNotFoundErrorE_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) fills path + 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) and files (all of them). files is 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, prefix and files are all absent and only meta carries 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

On this page