Handler API
Once you have a handler for an entity, everything you do with its files goes through it. This chapter walks through the operations in the order you will usually need them: read, add, replace, change metadata, rename, delete and reorder. Keep one rule in mind throughout: the handler only changes the in-memory entity, so saving it to the database is always your job.
Creating a handler
const h = attachments.user(entity);
Each key on h is a CategoryHandler for the corresponding category:
h.avatar
h.gallery
Read
h.avatar.list()
h.avatar.get('photo.jpg')
Write
const { attachment, rollback } = await h.avatar.add(file, {});
- Runs the middleware pipeline
- Sanitizes filename (strips diacritics, special chars) — configurable or disableable via
sanitize option
- Resolves name collisions:
photo.png → photo(1).png → photo(2).png …
- Bumps version counter; assigns new
version to the item
- On first add for a category, generates a
groupId (UUID → base36)
const { attachment, rollback } = await h.avatar.replace('photo.jpg', file, {});
- Runs the middleware pipeline
- Overwrites the physical file in-place; bumps version → new URL (cache-busted)
- Throws
AttachmentValidationError if filename not found
const updated = await h.avatar.updateMeta('photo.jpg', { caption: 'Hello' });
No file I/O. Bumps version → new URL. Throws if not found.
rename(oldName, newName)
await h.avatar.rename('photo.jpg', 'profile.jpg');
Sanitizes newName. Throws if the sanitized name already exists in the category.
delete(filename)
await h.avatar.delete('photo.jpg');
Deletes the physical file and removes the record. Does not bump the version counter.
reorder(filenames)
h.gallery.reorder(['b.png', 'a.png', 'c.png']);
Reorders the JSON array. No file I/O. No version bump.
purge() — entity level
await h.purge();
Deletes every file across all categories. Sets entity.attachments = {}. Requires a provider to be configured. Persist with prisma.update afterwards.
Rollback pattern
add and replace return a rollback function that deletes the physical file if the subsequent DB write fails:
const { attachment, rollback } = await h.avatar.add(file, meta);
try {
await prisma.course.update({
where: { id },
data: { attachments: record.attachments as object },
});
} catch (err) {
await rollback();
throw err;
}
The rollback pattern is worth a closer look, because storage and database are two separate systems and cannot be updated in one transaction. The file is written first. If the database write then fails, calling rollback() removes the file again, which keeps the two in sync.
flowchart TD
A[add / replace] --> B{DB write OK?}
B -- yes --> C[Done]
B -- no --> D[rollback()]
D --> E[Physical file deleted]
E --> F[Rethrow error]
DefineAttachmentsOptions
| Option |
Type |
Default |
Description |
provider |
StorageProvider |
— |
Storage backend (local or S3) |
servePrefix |
string |
'/file' |
URL prefix for generated file URLs |
nextGroupId |
(entityType, entityId, category) => Promise<string> |
UUID → base36 |
Custom group ID generator |
sanitize |
false | true | (name) => string |
true (built-in) |
Filename sanitization |
findUnique |
(existing, name) => string |
base(n)ext |
Collision resolution for add() |
eventManager |
EventEmitter |
— |
Event bus for attachment lifecycle events |
Handler API
Once you have a handler for an entity, everything you do with its files goes through it. This chapter walks through the operations in the order you will usually need them: read, add, replace, change metadata, rename, delete and reorder. Keep one rule in mind throughout: the handler only changes the in-memory entity, so saving it to the database is always your job.
Creating a handler
const h = attachments.user(entity); // entity must include the `attachments` field from the database rowEach key on
his aCategoryHandlerfor the corresponding category:h.avatar // CategoryHandler for 'avatar' h.gallery // CategoryHandler for 'gallery'Read
h.avatar.list() // AttachmentData[] — all files in category h.avatar.get('photo.jpg') // AttachmentData | undefinedWrite
add(file, meta)const { attachment, rollback } = await h.avatar.add(file, {});sanitizeoptionphoto.png→photo(1).png→photo(2).png…versionto the itemgroupId(UUID → base36)replace(filename, file, meta)const { attachment, rollback } = await h.avatar.replace('photo.jpg', file, {});AttachmentValidationErroriffilenamenot foundupdateMeta(filename, meta)const updated = await h.avatar.updateMeta('photo.jpg', { caption: 'Hello' });No file I/O. Bumps version → new URL. Throws if not found.
rename(oldName, newName)await h.avatar.rename('photo.jpg', 'profile.jpg');Sanitizes
newName. Throws if the sanitized name already exists in the category.delete(filename)await h.avatar.delete('photo.jpg');Deletes the physical file and removes the record. Does not bump the version counter.
reorder(filenames)h.gallery.reorder(['b.png', 'a.png', 'c.png']);Reorders the JSON array. No file I/O. No version bump.
purge()— entity levelawait h.purge();Deletes every file across all categories. Sets
entity.attachments = {}. Requires a provider to be configured. Persist withprisma.updateafterwards.Rollback pattern
addandreplacereturn arollbackfunction that deletes the physical file if the subsequent DB write fails:const { attachment, rollback } = await h.avatar.add(file, meta); try { await prisma.course.update({ where: { id }, data: { attachments: record.attachments as object }, }); } catch (err) { await rollback(); throw err; }The rollback pattern is worth a closer look, because storage and database are two separate systems and cannot be updated in one transaction. The file is written first. If the database write then fails, calling
rollback()removes the file again, which keeps the two in sync.flowchart TD A[add / replace] --> B{DB write OK?} B -- yes --> C[Done] B -- no --> D[rollback()] D --> E[Physical file deleted] E --> F[Rethrow error]DefineAttachmentsOptionsproviderStorageProviderservePrefixstring'/file'nextGroupId(entityType, entityId, category) => Promise<string>sanitizefalse | true | (name) => stringtrue(built-in)findUnique(existing, name) => stringbase(n)extadd()eventManagerEventEmitter