Skip to content
Attachment
Language:en Author:AtomForge

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;          // 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> type

All 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}
}

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&#40;&#41;] --> 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/',             // 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: '...',
});
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) { /* log, queue job, etc. */ }
};

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 },
  });
});