Storage
The last chapter looks under the hood. You will see what is stored in the JSON column, how a file ends up on disk or in a bucket, how to configure each provider, and which events the library emits so that you can react to changes. You do not need any of this to get started, but it helps a lot when you debug, migrate data or write your own provider.
JSON column structure
The attachments column holds a Record<category, CategoryStore>:
interface CategoryStore {
i: string;
v: number;
f: AttachmentItem[];
}
interface AttachmentItem {
n: string;
v: string;
s: number;
t: number;
x: unknown;
}
Example stored value:
{
"avatar": {
"i": "abc123",
"v": 3,
"f": [{ "n": "photo.jpg", "v": "3", "s": 42000, "t": 1743076800, "x": {} }]
}
}
All read methods return this shape:
interface AttachmentData<TMeta = unknown> {
filename: string;
version: string;
groupId: string;
size: number;
uploadedAt: number;
meta: TMeta;
url: string;
}
StorageProvider interface
interface StorageProvider {
save(path: string, file: File): Promise<void>;
read(path: string): Promise<Buffer>;
stream(path: string): ReadableStream<Uint8Array>;
delete(path: string): Promise<void>;
exists(path: string): Promise<boolean>;
rename(oldPath: string, newPath: string): Promise<void>;
setEventManager?(em: EventEmitter): void;
}
Physical file layout
Local provider
Files live at:
{shard}/{groupId}/{filename}
where shard = groupId.slice(0, 2).
Example: ab/abc123/photo.jpg
S3 / MinIO
No shard — object storage has no filesystem limitations:
{groupId}/{filename}
Example: abc123/photo.jpg
An optional provider prefix namespaces all object keys. This lets original files and
generated image variants share one bucket while remaining independently manageable:
files/abc123/photo.jpg
images/abc123-1.e.400x400.photo.jpg.webp
Saves always set Cache-Control: public, max-age=31536000, immutable.
Replacing a file never changes where it lives on disk. What changes is the version number, and since the version is part of the URL, browsers and CDNs fetch the new file instead of a cached old one.
flowchart LR
R[replace()] --> W[Overwrite same physical path]
R --> V[Bump version in JSON]
V --> U[New URL groupId-version]
U --> CB[Cache busted]
Local provider
import { createLocalProvider } from '@atom-forge/attachment';
const provider = createLocalProvider('./var/uploads');
No extra dependencies — uses Node.js built-ins (fs/promises).
S3 / MinIO provider
import { createS3Provider } from '@atom-forge/attachment';
const provider = createS3Provider({
bucket: 'assets',
prefix: 'files/',
region: 'eu-central-1',
endpoint: 'http://minio:9000',
forcePathStyle: true,
accessKeyId: '...',
secretAccessKey: '...',
});
| Option |
Required |
Description |
bucket |
yes |
S3 bucket name |
prefix |
no |
Object-key namespace; leading/trailing slashes are normalized |
region |
yes |
AWS region |
endpoint |
MinIO only |
Custom endpoint URL |
forcePathStyle |
MinIO only |
Use http://host/bucket/key instead of virtual-hosted style |
accessKeyId |
yes |
Access key |
secretAccessKey |
yes |
Secret key |
One bucket for originals and image cache
Keep the two-provider setup, but point both providers at the same bucket with
different prefixes:
const sourceProvider = createS3Provider({ bucket: 'assets', prefix: 'files/', });
const thumbProvider = createS3Provider({ bucket: 'assets', prefix: 'images/', });
Lifecycle expiration can then target only images/, leaving files/ untouched.
Dependency: @aws-sdk/client-s3
Event system
Attach an event manager to receive lifecycle events:
import { type EventEmitter } from '@atom-forge/attachment';
const myEventManager: EventEmitter = {
trigger(event) { }
};
defineAttachments({...}, {
provider,
eventManager: myEventManager,
});
Events fired by defineAttachments
| Event |
When |
Payload |
attachment:add |
file uploaded, store updated |
entityType, entityId, category, groupId, attachment |
attachment:replace |
file overwritten, store updated |
…, oldAttachment, attachment |
attachment:delete |
file deleted, store updated |
entityType, entityId, category, groupId, filename |
attachment:rename |
file renamed |
…, oldName, newName |
attachment:meta-updated |
only meta changed |
entityType, entityId, category, groupId, attachment |
attachment:purge |
all files deleted |
entityType, entityId |
Events fired by S3 provider
| Event |
When |
storage:rename-cleanup-failed |
CopyObject ok, DeleteObject failed |
Prisma middleware
Wire up automatic entity:deleted events on Prisma deletes:
import { createPrismaMiddleware } from '@atom-forge/attachment';
prisma.$use(createPrismaMiddleware(eventManager));
Handle the event to purge attachments when the entity is deleted:
eventManager.on('entity:deleted', async ({ model, entity }) => {
const handler = attachments[model.toLowerCase()]?.(entity);
await handler?.purge();
await prisma[model.toLowerCase()].update({
where: { id: entity.id },
data: { attachments: entity.attachments as object },
});
});
Storage
The last chapter looks under the hood. You will see what is stored in the JSON column, how a file ends up on disk or in a bucket, how to configure each provider, and which events the library emits so that you can react to changes. You do not need any of this to get started, but it helps a lot when you debug, migrate data or write your own provider.
JSON column structure
The
attachmentscolumn holds aRecord<category, CategoryStore>:interface CategoryStore { i: string; // groupId (base36, auto-generated UUID→base36 on first add) v: number; // version sequence counter (monotonically increasing) f: AttachmentItem[]; } interface AttachmentItem { n: string; // filename v: string; // version at time of add/replace (base36) s: number; // size in bytes t: number; // uploadedAt (Unix epoch) x: unknown; // caller-supplied meta }Example stored value:
{ "avatar": { "i": "abc123", "v": 3, "f": [{ "n": "photo.jpg", "v": "3", "s": 42000, "t": 1743076800, "x": {} }] } }Public
AttachmentData<TMeta>typeAll read methods return this shape:
interface AttachmentData<TMeta = unknown> { filename: string; version: string; // base36 groupId: string; // base36 size: number; uploadedAt: number; // Unix epoch meta: TMeta; url: string; // computed: /{servePrefix}/{groupId}-{version}/{filename} }StorageProviderinterfaceinterface StorageProvider { save(path: string, file: File): Promise<void>; read(path: string): Promise<Buffer>; stream(path: string): ReadableStream<Uint8Array>; delete(path: string): Promise<void>; exists(path: string): Promise<boolean>; rename(oldPath: string, newPath: string): Promise<void>; setEventManager?(em: EventEmitter): void; }Physical file layout
Local provider
Files live at:
where
shard = groupId.slice(0, 2).Example:
ab/abc123/photo.jpgS3 / MinIO
No shard — object storage has no filesystem limitations:
Example:
abc123/photo.jpgAn optional provider prefix namespaces all object keys. This lets original files and generated image variants share one bucket while remaining independently manageable:
Saves always set
Cache-Control: public, max-age=31536000, immutable.Replacing a file never changes where it lives on disk. What changes is the version number, and since the version is part of the URL, browsers and CDNs fetch the new file instead of a cached old one.
Local provider
import { createLocalProvider } from '@atom-forge/attachment'; const provider = createLocalProvider('./var/uploads');No extra dependencies — uses Node.js built-ins (
fs/promises).S3 / MinIO provider
import { createS3Provider } from '@atom-forge/attachment'; const provider = createS3Provider({ bucket: 'assets', prefix: 'files/', // optional object-key namespace region: 'eu-central-1', endpoint: 'http://minio:9000', // required for MinIO; omit for AWS S3 forcePathStyle: true, // required for MinIO accessKeyId: '...', secretAccessKey: '...', });bucketprefixregionendpointforcePathStylehttp://host/bucket/keyinstead of virtual-hosted styleaccessKeyIdsecretAccessKeyOne bucket for originals and image cache
Keep the two-provider setup, but point both providers at the same bucket with different prefixes:
const sourceProvider = createS3Provider({ bucket: 'assets', prefix: 'files/', /* ... */ }); const thumbProvider = createS3Provider({ bucket: 'assets', prefix: 'images/', /* ... */ });Lifecycle expiration can then target only
images/, leavingfiles/untouched.Dependency:
@aws-sdk/client-s3Event system
Attach an event manager to receive lifecycle events:
import { type EventEmitter } from '@atom-forge/attachment'; const myEventManager: EventEmitter = { trigger(event) { /* log, queue job, etc. */ } }; defineAttachments({...}, { provider, eventManager: myEventManager, });Events fired by
defineAttachmentsattachment:addattachment:replaceattachment:deleteattachment:renameattachment:meta-updatedattachment:purgeEvents fired by S3 provider
storage:rename-cleanup-failedPrisma middleware
Wire up automatic
entity:deletedevents on Prisma deletes:import { createPrismaMiddleware } from '@atom-forge/attachment'; prisma.$use(createPrismaMiddleware(eventManager));Handle the event to purge attachments when the entity is deleted:
eventManager.on('entity:deleted', async ({ model, entity }) => { const handler = attachments[model.toLowerCase()]?.(entity); await handler?.purge(); await prisma[model.toLowerCase()].update({ where: { id: entity.id }, data: { attachments: entity.attachments as object }, }); });