The two layers
Storage (layer 1) is a disk-agnostic filesystem; media-library (layer 2) attaches files to entities on top of it. How they relate, and why they're split.
The package is two layers stacked in one module. Understanding the seam between them is the key to using media well — and to knowing when you only need the bottom half.
Why split at all?
Plenty of file handling is not about entities. Writing a generated PDF to disk, caching a rendered image, letting the mail channel attach a buffer — these want a filesystem, nothing more. Meanwhile, "this user's avatar" or "this post's gallery with thumbnails" is genuinely a media library: files that belong to records, with rules and variants.
Bundling both into one rigid API would make the simple case carry the weight of the complex one. So media keeps them as two layers with a single, narrow seam: the media-library writes bytes through a disk and records metadata through a store. Everything else is independent.
Layer 1 — Storage
A StorageManager holds named disks; each disk is a StorageDriver. You get it from MediaService.disk():
const disk = media.disk('s3'); // a named disk
const def = media.disk(); // the configured default disk
await disk.put('a/b.png', buffer, { contentType: 'image/png', visibility: 'public' });
await disk.get('a/b.png'); // Buffer
await disk.stream('a/b.png'); // Readable — stream large files, don't buffer
await disk.url('a/b.png'); // stable public URL (throws if the disk has none)
await disk.temporaryUrl('a/b.png', 300); // signed, expiring URL (presign-capable disks)
await disk.exists('a/b.png'); // boolean
await disk.size('a/b.png'); // bytes
await disk.delete('a/b.png'); // idempotent — no error if absent
await disk.copy(from, to);
await disk.move(from, to);Capabilities
Drivers are honest about what they can do, and the rest of the library reads this rather than special-casing vendors:
disk.capabilities; // { presign: boolean; multipart: boolean; publicUrls: boolean }publicUrls gates url(), presign gates temporaryUrl() and is consulted by uploadMode to decide direct vs proxied uploads. A driver that can't do something throws UnsupportedOperationError instead of silently faking it.
Errors
The storage layer raises typed errors with stable codes — FileNotFoundError (MEDIA_FILE_NOT_FOUND), UnknownDiskError (MEDIA_UNKNOWN_DISK), UnsupportedOperationError (MEDIA_UNSUPPORTED_OP) — so you can branch on err.code rather than message strings.
Raw-storage consumers
Other libraries (mail attachments, durable large-file steps) that just need a filesystem import the facade directly via the subpath, without pulling in the media-library layer:
import { StorageManager, type StorageDriver } from '@dudousxd/nestjs-media/storage';This is the whole reason media absorbed storage instead of shipping a separate nestjs-storage: there's one filesystem abstraction, and the media library is just its biggest consumer.
Layer 2 — Media-library
MediaLibrary (exposed as media.library) attaches files to owning entities in named collections. It writes the bytes through a disk and persists a MediaRecord through a MediaStore.
const record = await media.library.attach({
ownerType: 'Post',
ownerId: '42',
collection: 'gallery',
fileName: 'photo.png',
mimeType: 'image/png',
contents: buffer, // Buffer | Readable
// optional: name, size, customProperties, disk (override)
});
await media.library.list('Post', '42', 'gallery'); // ordered MediaRecord[]
await media.library.list('Post', '42'); // all collections for the owner
await media.library.url(record.id); // url to the original
await media.library.url(record.id, 'thumb'); // url to a conversion (lazily generated)
await media.library.delete(record.id); // removes record + bytes + every conversionattach derives a deterministic storage path (ownerType/ownerId/collection/<id>/fileName), writes the bytes, reads the size back if you didn't supply it, computes the next order in the collection, and saves the record. delete cleans up the original and all generated conversion files before removing the row — no orphans.
Binding an owner
Repeating ownerType/ownerId on every call gets old. media.library.for(type, id) binds them once — the table-model equivalent of spatie's HasMedia/InteractsWithMedia:
const post = media.library.for('Post', post.id); // id coerced to a string
await post.attach({ collection: 'gallery', fileName, mimeType, contents });
await post.list('gallery'); // ordered MediaRecord[]
await post.list(); // every collection for this posturl and delete act on a record id (not an owner), so they stay on media.library.
The MediaRecord
interface MediaRecord {
id: string;
ownerType: string; ownerId: string; collection: string;
name: string; fileName: string; mimeType: string; size: number;
disk: string; path: string; order: number;
customProperties: Record<string, unknown>;
conversions: Record<string, { path: string; disk: string }>;
createdAt: Date; updatedAt: Date;
}Collections
Collections are declared at module registration and control behavior:
collections: [
// single-file: attaching replaces the previous file (record + bytes)
{ name: 'avatar', single: true, acceptsMimeTypes: ['image/png', 'image/jpeg'] },
// multi-file with a target disk override and conversions
{ name: 'gallery', disk: 's3', conversions: [{ name: 'thumb', width: 200 }] },
]| Option | Effect |
|---|---|
single | Attaching replaces any existing media in the collection (old record + bytes are deleted first) |
disk | Target disk for this collection (else the module default) |
acceptsMimeTypes | Allow-list; other types are rejected with MimeNotAllowedError before any bytes are written |
conversions | Image presets available for this collection (see Conversions) |
An unregistered collection name is permitted with default behavior (multi-file, default disk, no conversions) — so you can attach to ad-hoc collections without declaring every one up front, and declare only the ones that need rules.
The media table is polymorphic and managed by your chosen ORM store — TypeORM, MikroORM, Drizzle, or Prisma, all behind the same MediaStore contract.