Getting Started
This chapter takes you from an empty project to a working upload in four steps: define what can be attached, upload a file, read it back on the server, and finally read it in the browser without an extra API call. Along the way you will meet the main ideas of the library: categories, the handler and the middleware pipeline.
What it does
- Attaches files to any Prisma entity via a
Json? column called attachments
- Supports multiple categories per entity (e.g.
avatar, gallery, documents)
- Validates and transforms uploads via a composable middleware pipeline
- Generates cache-busted serving URLs automatically
- Provides on-demand thumbnail generation for images (WebP output, flat disk cache)
- Ships a typed client-side handler that reads entity data directly — no extra API call
The diagram shows how the pieces fit together. An upload passes through the middleware pipeline and ends up in two places: the file goes to storage and its metadata goes into the JSON column. Everything read later, by the client handler or the file and image servers, comes from those two places.
flowchart LR
U[Upload] --> M[Middleware pipeline]
M --> P[(StorageProvider<br/>local / S3)]
M --> J[attachments JSON column]
J --> C[Client handler]
J --> S[File & image server]
P --> S
Installation
npm install @atom-forge/attachment
Peer dependencies
| Package |
Version |
Required by |
sharp |
>=0.32 |
toWebP(), imgstat(), generateThumbnail() |
music-metadata |
>=11 |
getMp3Duration() |
zod |
>=4 |
schema validation middleware |
Peer deps are optional at the package level — import only what you use.
1. Define your attachment schema
import { defineAttachments, count, mime, toWebP, imgstat } from '@atom-forge/attachment';
import { createLocalProvider } from '@atom-forge/attachment';
const attachments = defineAttachments({
user: defineAttachments.entity({
avatar: [count(1), mime('image/*'), toWebP(80, 400, 400), imgstat()],
gallery: [mime('image/*'), toWebP(80)],
}, { idField: 'neptunCode' }),
course: defineAttachments.entity({
avatar: [count(1), mime(['image/jpeg', 'image/png', 'image/webp'])],
}),
}, {
provider: createLocalProvider('./var/uploads'),
servePrefix: '/file',
});
defineAttachments.entity(categories, options?) defines one entity type.
idField tells the handler which field on the entity is the primary key (default: 'id').
2. Upload a file
const userRecord = await prisma.user.findUniqueOrThrow({ where: { neptunCode } });
const h = attachments.user(userRecord);
const { attachment, rollback } = await h.avatar.add(uploadedFile, {});
try {
await prisma.user.update({
where: { neptunCode },
data: { attachments: userRecord.attachments as object },
});
} catch (err) {
await rollback();
throw err;
}
The sequence below is what step 2 does behind the scenes. Notice that the handler returns a rollback function: if saving to the database fails, you can use it to remove the file that was just stored, so no orphaned files are left behind.
sequenceDiagram
participant App
participant H as Handler
participant P as Provider
participant DB as Prisma
App->>H: avatar.add(file, meta)
H->>H: run middleware
H->>P: save(file)
H-->>App: attachment + rollback
App->>DB: update(attachments)
alt DB error
App->>H: rollback()
H->>P: delete(file)
end
3. Read files on the server
const h = attachments.user(userRecord);
h.avatar.list()
h.avatar.get('photo.jpg')
4. Client-side handler
The client-side handler reads the entity's attachments JSON directly — no server round-trip required.
import { makeAttachmentHandler } from '@atom-forge/attachment/client';
import { imageVariants } from './image-variants.js';
const h = makeAttachmentHandler(imageVariants, { servePrefix: '/file', thumbPrefix: '/img' });
const item = h.course(course).avatar.first;
const all = h.user(user).gallery.all;
item?.filename
item?.url
item?.meta
item?.img.avatar()
item?.img.avatar(2)
Getting Started
This chapter takes you from an empty project to a working upload in four steps: define what can be attached, upload a file, read it back on the server, and finally read it in the browser without an extra API call. Along the way you will meet the main ideas of the library: categories, the handler and the middleware pipeline.
What it does
Json?column calledattachmentsavatar,gallery,documents)The diagram shows how the pieces fit together. An upload passes through the middleware pipeline and ends up in two places: the file goes to storage and its metadata goes into the JSON column. Everything read later, by the client handler or the file and image servers, comes from those two places.
Installation
Peer dependencies
sharp>=0.32toWebP(),imgstat(),generateThumbnail()music-metadata>=11getMp3Duration()zod>=4Peer deps are optional at the package level — import only what you use.
1. Define your attachment schema
import { defineAttachments, count, mime, toWebP, imgstat } from '@atom-forge/attachment'; import { createLocalProvider } from '@atom-forge/attachment'; const attachments = defineAttachments({ user: defineAttachments.entity({ avatar: [count(1), mime('image/*'), toWebP(80, 400, 400), imgstat()], gallery: [mime('image/*'), toWebP(80)], }, { idField: 'neptunCode' }), course: defineAttachments.entity({ avatar: [count(1), mime(['image/jpeg', 'image/png', 'image/webp'])], }), }, { provider: createLocalProvider('./var/uploads'), servePrefix: '/file', // nextGroupId: optional — defaults to crypto.randomUUID() → base36 // Override if you need deterministic or DB-backed group IDs: // nextGroupId: async (entityType, entityId, category) => myIdGenerator(...) });defineAttachments.entity(categories, options?)defines one entity type.idFieldtells the handler which field on the entity is the primary key (default:'id').2. Upload a file
const userRecord = await prisma.user.findUniqueOrThrow({ where: { neptunCode } }); const h = attachments.user(userRecord); const { attachment, rollback } = await h.avatar.add(uploadedFile, {}); try { await prisma.user.update({ where: { neptunCode }, data: { attachments: userRecord.attachments as object }, }); } catch (err) { await rollback(); // deletes the physical file on DB failure throw err; }The sequence below is what step 2 does behind the scenes. Notice that the handler returns a
rollbackfunction: if saving to the database fails, you can use it to remove the file that was just stored, so no orphaned files are left behind.sequenceDiagram participant App participant H as Handler participant P as Provider participant DB as Prisma App->>H: avatar.add(file, meta) H->>H: run middleware H->>P: save(file) H-->>App: attachment + rollback App->>DB: update(attachments) alt DB error App->>H: rollback() H->>P: delete(file) end3. Read files on the server
const h = attachments.user(userRecord); h.avatar.list() // AttachmentData[] h.avatar.get('photo.jpg') // AttachmentData | undefined4. Client-side handler
The client-side handler reads the entity's
attachmentsJSON directly — no server round-trip required.import { makeAttachmentHandler } from '@atom-forge/attachment/client'; import { imageVariants } from './image-variants.js'; const h = makeAttachmentHandler(imageVariants, { servePrefix: '/file', thumbPrefix: '/img' }); const item = h.course(course).avatar.first; // ItemView | undefined const all = h.user(user).gallery.all; // ItemView[] item?.filename // 'photo.jpg' item?.url // '/file/abc123-3/photo.jpg' item?.meta // unknown — cast to your meta type item?.img.avatar() // '/img/abc123-3/e.400x400/photo.jpg.webp' item?.img.avatar(2) // '/img/abc123-3/e.800x800/photo.jpg.webp' (2x retina)