Configuration
Everything lives in config/attachment.ts, built with defineConfig. The only required field is storage.
// config/attachment.ts - generated by `node ace configure @jrmc/adonis-attachment`
import { defineConfig, LocalFileStorage } from '@jrmc/adonis-attachment'
export default defineConfig({
storage: LocalFileStorage.fromApp,
})A common adapter pattern
storage, store, and queue each accept an adapter instance or a synchronous/asynchronous factory (app, context) => adapter. Built-in adapters offer fromApp when defaults are sufficient and configure(options) when you need overrides:
import { defineConfig, AdonisDriveStorage, MemoryAttachmentQueue } from '@jrmc/adonis-attachment'
import { LucidAttachmentStore } from '@jrmc/adonis-attachment/lucid'
export default defineConfig({
storage: AdonisDriveStorage.configure({ disk: 's3' }),
store: LucidAttachmentStore.configure({ tableName: 'media_attachments' }),
queue: MemoryAttachmentQueue.configure({ concurrency: 2 }),
})The default forms are AdonisDriveStorage.fromApp, LucidAttachmentStore.fromApp, and MemoryAttachmentQueue.fromApp. configure() returns a factory; it does not initialize an adapter immediately. Adonis Queue requires an application job, so use AdonisAttachmentQueue.configure({ job: GenerateAttachmentVariants }).
| Setting | Responsibility | When omitted |
|---|---|---|
storage | File bytes and storage URLs | Required |
store | Attachment records and processing persistence | Disabled; false also disables it |
queue | Job dispatch or in-process execution | Memory queue |
Factories resolve in this order: storage, store, queue. All receive defaults and variant. Storage receives the explicitly configured defaultDisk, if any. Store and queue receive the resolved disk: root defaultDisk, then the storage adapter's default, then fs. The queue context also supplies an optional handler, assembled from the store or overridden by jobHandler. A custom in-process queue can use it; external adapters usually dispatch jobs and build their processor in the worker instead.
Public types include AttachmentAdapterFactory, AttachmentStorageConfig, AttachmentStoreConfig, AttachmentQueueAdapterConfig and their role-specific contexts. Existing one-argument factories, queue driver shortcuts and named connections remain supported. Do not resolve jrmc.attachment or run the handler inside a config factory: configuration must finish before processing starts. Reuse stateful adapters per application when factories can be resolved for multiple bindings.
Storage
Storage decides where the bytes go. Pick one.
Import the built-in storage classes from the package root, or use the dedicated entry points @jrmc/adonis-attachment/storages/local-file and @jrmc/adonis-attachment/storages/adonis-drive. The former /adapters/... entry points are replaced by /storages/... in beta.2.
Local filesystem (default)
storage: LocalFileStorage.fromApp,LocalFileStorage writes under storage/attachments, on the fs disk. It creates parent directories, writes atomically (temp file + rename), and refuses paths that escape its root. Pass baseUrl when those files are exposed publicly and you need public URLs:
storage: LocalFileStorage.configure({
baseUrl: 'https://app.example.test/uploads',
})Adonis Drive (S3, GCS, ...)
Install @adonisjs/drive, configure config/drive.ts, then:
import { AdonisDriveStorage, defineConfig } from '@jrmc/adonis-attachment'
export default defineConfig({
storage: AdonisDriveStorage.fromApp, // uses your default Drive disk
})Your own backend
Any object implementing AttachmentStorage (write, read, remove) works. The fallback disk name is fs unless your adapter exposes defaultDisk or you set defaultDisk explicitly.
To override the default Drive disk, use storage: AdonisDriveStorage.configure({ disk: 's3' }).
Store
storage handles file bytes. store handles attachment data and the persistence of background-processing results. The two choices are independent.
Lucid is never detected automatically. Select it explicitly:
import { defineConfig, AdonisDriveStorage } from '@jrmc/adonis-attachment'
import { LucidAttachmentStore } from '@jrmc/adonis-attachment/lucid'
export default defineConfig({
storage: AdonisDriveStorage.fromApp,
store: LucidAttachmentStore.fromApp,
})Use LucidAttachmentStore.configure({ tableName: 'media_attachments' }) for custom tables, or LucidAttachmentStore.configure({ legacy: { models: { users: () => import('#models/user') } } }) for legacy JSON fields. Both options can be combined; tables and JSON can coexist.
Omitting store, or setting store: false, leaves data persistence to the application. No read route or automatic memory processing is enabled in that mode. Draft creation, file storage, synchronous metadata extraction and manual processing remain available.
For another ORM, provide an AttachmentStore object or a factory (app, context) => store. The context contains defaultDisk, defaults, and variant. Capabilities are optional:
| Store member | Purpose |
|---|---|
repository | Required. Read originals and variants by ID; optionally resolve contextual references. Supplies the repository for explicit router.attachments() routes. |
persistence | Optional. Existing AttachmentPersistence lifecycle/transaction contract, for application-owned lifecycle integration. |
metadataPersister | Required for media.metadataPolicy.mode: 'deferred'. Persists extracted metadata. |
variants(service, converters, context) | Required to persist generated variants. Returns a VariantGenerator, including the adapter's replacement and cleanup policy. |
The configured store is used by memory processing and by createAttachmentProcessor(app) in external workers. A read-only store is supported; requesting unsupported processing fails explicitly. Providing a store does not create another ORM's model decorators or schedule jobs before the application's transaction commits. Follow Creating a store for a complete class and factory, then custom persistence for lifecycle and transaction details.
Migrating from earlier v6 betas
| Previous configuration | Replacement |
|---|---|
route: false | Omit router.attachments() in start/routes.ts |
route: { prefix } or automatic read route | Declare router.attachments('/media/:id/:name?') and select allowed disks |
| Automatic Lucid detection | store: LucidAttachmentStore.fromApp |
integrations.lucid.tableName | store: LucidAttachmentStore.configure({ tableName }) |
integrations.legacy.models | legacy.models inside LucidAttachmentStore.configure() |
Root repository | store.repository |
media.metadataPersister | store.metadataPersister |
Root processor | Store processing capabilities, or jobHandler: () => (job) => processor.process(job) for a fully custom processor |
The removed keys fail with a migration error. queue, converters, events, defaults, variant, and media extraction settings keep their roles. No data or schema migration is needed solely for this configuration change.
Default persistence options
defaults sets the lowest-priority file options. They're resolved per setting, from most to least specific:
createFrom*(...) options > decorator options > defaultsSet an option to null at a higher level to clear an inherited value. This restores the package fallback; it is not equivalent to false (for example, rename: null restores generated names, while rename: false uses the client filename).
export default defineConfig({
storage: LocalFileStorage.fromApp,
store: LucidAttachmentStore.fromApp,
defaults: {
folder: 'uploads',
rename: true, // store under a generated name instead of the client name
normalizeFileName: true, // make supplied filenames portable across storage backends
variants: ['thumbnail'],
},
variant: { basePath: 'variants/:id' }, // prefix for all generated variants
})Persistence defaults accept disk, folder, rename, normalizeFileName, meta, preComputeUrl, and variants. variant is a separate root configuration section.
| Option | Accepted value | Fallback when unset | Scope |
|---|---|---|---|
disk | Storage disk name | Storage adapter default | Config defaults, decorator, manager |
folder | Relative path or async callback | Storage root | Config defaults, decorator, manager |
rename | Boolean or async filename callback | true | Config defaults, decorator, manager |
normalizeFileName | Boolean | true | Config defaults, decorator, manager |
meta | Boolean | Extraction not requested | Config defaults, decorator, manager |
preComputeUrl | Boolean | false | Same priority; consumed on Lucid relation reads |
variants | Array of configured keys | No automatic variants | Same priority; automatic scheduling with Lucid |
variant.basePath | Relative path, :attachmentField parameters, or async callback | No prefix | Root configuration; prefixes converter folders |
originalName, mimeType | String | Derived from the source | Manager options only |
metadata | Metadata object | No supplied metadata | Manager options only |
maxBytes | Positive integer | No size ceiling | sources.maxBytes and manager options |
meta requests extraction, whereas metadata supplies already known values. The media policy controls whether extraction is synchronous, deferred, or disabled. Outside Lucid, the application owns database persistence and post-commit job scheduling.
meta activates the default metadata extractors during persist(). With Lucid relations, preComputeUrl calculates the public URL when the relation is read and keeps it only in memory. With Lucid relations, variants schedules the listed keys after the blob and its link are committed. It is resolved with the same priority as the other persistence options.
Folder and rename defaults
folder accepts a relative path or an async callback. rename accepts true, false, or a callback that returns the stored filename. normalizeFileName defaults to true and makes names supplied by rename: false or a callback portable across storage backends. Defaults may use V5-style :attribute parameters, which are resolved only when a model context exists, such as a Lucid decorator or relation:
Set normalizeFileName: false only when the selected storage backend accepts the supplied filename unchanged.
defaults: {
folder: 'uploads/:name',
rename: () => ':name-file.jpg',
}String model attributes are lowercased, HTML-escaped, and slugified. A parameter that is unknown or not a string remains unchanged. See Creating attachments for callbacks and standalone behavior.
Variant folders
variant.basePath is the shared prefix for generated variants. Each converter's optional folder is a subfolder inside it. Both accept attachment parameters or an async callback receiving { attachment }. These fields describe the source attachment, not a Lucid owner: a table-backed blob may be shared by several owners. Their values are inserted verbatim.
| Parameter | Attachment value |
|---|---|
:id | The attachment identifier. |
:disk | The selected storage disk. |
:name | The stored filename, including its extension. |
:originalName | The original filename supplied by the client or source. |
:path | The complete stored path of the original. |
:extname | The original extension, without the leading dot. |
:mimeType | The detected or supplied MIME type. |
:blurhash | The blurhash, when it has been computed. |
In particular, :name preserves the storage filename and its extension. This differs from defaults.folder: model parameters in that option are slugified, whereas variant parameters always refer to the source attachment and are not transformed.
variant: { basePath: 'variants/:id' },
converters: {
thumbnail: {
resize: { width: 320 },
format: 'webp',
folder: 'images', // variants/<attachment id>/images/<generated file>
},
}Use a callback when the location needs the original path:
import { dirname, join } from 'node:path/posix'
variant: {
basePath: ({ attachment }) => join(dirname(attachment.path), 'variants', attachment.name),
}Or use a callback to group every generated variant by the date at which it is generated:
import { DateTime } from 'luxon'
variant: { basePath: 'variants' },
converters: {
thumbnail: {
resize: { width: 320 },
format: 'webp',
folder: () => DateTime.now().toFormat('yyyy/LL'),
},
}This writes the thumbnail to a path such as variants/2026/09/thumbnail.webp.
URLs
Storage URL methods depend on adapter capabilities. AdonisDriveStorage uses the selected Drive disk; LocalFileStorage requires baseUrl for public URLs. preComputeUrl resolves public URLs during relation reads and keeps them in memory.
See File URLs for all ways to obtain a URL: application routes, singular relations, collections, the service, signed URLs, precomputed .url, and legacy JSON.
Media binaries
Declare external media binaries once under media.binaries. The autodetected video, PDF, and office-document converters use these values. A command or timeout declared directly on a converter remains more specific and takes precedence.
const binaries = {
ffmpeg: { command: '/opt/media/bin/ffmpeg', timeout: 15_000 },
ffprobe: { command: '/opt/media/bin/ffprobe', timeout: 5_000 },
pdftoppm: { command: '/opt/media/bin/pdftoppm', timeout: 10_000 },
pdfinfo: { command: '/opt/media/bin/pdfinfo', timeout: 5_000 },
soffice: { command: '/opt/media/bin/libreoffice', timeout: 20_000 },
}
export default defineConfig({
storage: LocalFileStorage.fromApp,
media: {
binaries,
},
})The default metadata extractors reuse the ffprobe and pdfinfo declarations automatically. Converter-level values remain more specific and take precedence over shared binary values.
Media metadata
Set meta: true on an attachment or in defaults to enable the built-in profile. It extracts the historical metadata shape: dimension, orientation, date, host, gps, duration, codecs, PDF pages, and PDF version. No media.metadata entry is required.
For the default image profile, install the optional dependencies:
npm install sharp exifreader| Source | Default extraction | Requirement |
|---|---|---|
| Supported raster images | Sharp technical metadata, then EXIF/GPS where supported | sharp and exifreader |
| SVG | Sharp technical metadata, including dimensions; never EXIF | sharp only |
| Audio/video | Duration, codecs, and video dimensions | ffprobe executable |
| Dimensions, pages, version, and creation date | pdfinfo executable | |
| DOCX and other unsupported formats | No extracted metadata; the original can still be stored | None for metadata |
Sharp adds format, density, hasAlpha, and available page/frame information. EXIF enriches that result with fields such as orientation description, date, software, and GPS. Expanded ExifReader coordinates are normalized to gps.latitude, gps.longitude, and gps.altitude automatically; no application reader wrapper or type cast is needed.
Image readers are loaded only when a matching extraction runs, not when configuration is created. Missing required packages raise E_MISSING_PACKAGE; corrupt supported files and binary failures still raise errors. Unsupported formats are skipped rather than sent to an incompatible parser. In particular, meta: true does not imply that DOCX metadata is supported. The existing binary path configuration applies to ffprobe and pdfinfo unchanged.
Advanced: override the extractors
media.metadata completely replaces the default profile. Extractors receive the finalized attachment information and source bytes. Their results are merged in declaration order; metadata supplied by the application wins on key conflicts.
import { defineConfig, LocalFileStorage } from '@jrmc/adonis-attachment'
import { createDefaultMetadataExtractors } from '@jrmc/adonis-attachment/media/metadata'
export default defineConfig({
storage: LocalFileStorage.fromApp,
media: {
metadata: createDefaultMetadataExtractors({
ffprobe: { command: '/opt/media/bin/ffprobe', timeout: 5_000 },
pdfinfo: { command: '/opt/media/bin/pdfinfo', timeout: 5_000 },
}),
},
defaults: { meta: true },
})command accepts either an executable available on PATH or an absolute path. Set sharp, exif, ffprobe, or pdfinfo to false to disable an extractor. Use metadata: [] to disable all default extraction even when an attachment requests meta: true.
For example, createDefaultMetadataExtractors({ sharp: false }) keeps EXIF and the binary extractors but disables Sharp technical metadata, including SVG metadata. To customize Sharp, pass a metadata factory as sharp; the actual imported sharp function is accepted.
Performance policy
Metadata runs synchronously by default, preserving the v5 behavior: persist() waits for the extraction. Set command timeouts for external binaries to prevent a stalled process from holding a request indefinitely:
media: {
binaries: {
ffprobe: { timeout: 5_000 },
pdfinfo: { timeout: 5_000 },
},
}For video or PDF-heavy applications, defer extraction to the queue after the attachment row is committed. The Lucid store provides its metadata persister; another ORM must provide store.metadataPersister with a persistMetadata(attachment, metadata) method.
media: {
metadataPolicy: {
mode: 'deferred',
variants: false,
},
}variants defaults to true for v5 compatibility. Set it to false when thumbnails do not need their own metadata. This disables both synchronous extraction and deferred metadata jobs for variants, even with defaults.meta: true. An explicit meta: false on a generation request also overrides the global metadata default. Originals retain their own metadata settings. In deferred mode, configure the metadata processor shown in Background processing so workers handle extract-metadata jobs.
With custom persistence, call AttachmentService.scheduleMetadataExtraction(attachment) only after the record that owns the attachment has committed. This prevents a worker from updating a record that does not exist yet. See the complete custom-persistence example.
import { defineConfig, LocalFileStorage, type MediaMetadataExtractor } from '@jrmc/adonis-attachment'
const imageMetadata: MediaMetadataExtractor = {
supports: ({ attachment }) => attachment.mimeType.startsWith('image/'),
async extract({ body }) {
return { sourceBytes: body.byteLength }
},
}
export default defineConfig({
storage: LocalFileStorage.fromApp,
media: { metadata: [imageMetadata] },
})The default profile already includes Sharp. To explicitly replace the profile with only Sharp image metadata, use the optional adapter instead of writing the extractor yourself:
import sharp from 'sharp'
import { createSharpMetadataExtractor } from '@jrmc/adonis-attachment/media/sharp'
media: { metadata: [createSharpMetadataExtractor(sharp)] }For audio and video, createFfprobeMetadataExtractor() from @jrmc/adonis-attachment/media/binaries reads duration, codecs and video dimensions. It accepts the same command and timeout options when the v5 profile is not used.
Generated variants use the same extraction pipeline when their original was scheduled with meta: true. Converter-provided metadata still takes precedence over extracted values.
Source limits
sources configures the attachment manager. The package enforces no size policy by default - validate uploads in your app. You may set a technical byte ceiling:
export default defineConfig({
storage: LocalFileStorage.fromApp,
sources: { maxBytes: 10 * 1024 * 1024 }, // 10 MB, per source
})Lucid table names
By default the Lucid integration uses adonis_attachments (blobs) and adonis_attachment_links (links). The link table name is always derived from the blob table name with string.singular:
tableName | link table |
|---|---|
adonis_attachments | adonis_attachment_links |
media_attachments | media_attachment_links |
export default defineConfig({
storage: LocalFileStorage.fromApp,
store: LucidAttachmentStore.configure({ tableName: 'media_attachments' }),
})LucidAttachmentStore.fromApp uses the default names. configure() selects custom names and optionally enables legacy JSON processing. Use store: false when attachments should not use Lucid, even if Lucid is installed for the rest of the application.
WARNING
Use the same tableName when you generate the migration and at runtime - the models are pointed at these names at boot. Changing configuration does not rename existing tables. To keep an existing attachments and attachment_links schema, explicitly set store: LucidAttachmentStore.configure({ tableName: 'attachments' }).
Read route & background processing
storesupplies the repository, metadata persistence, and variant persistence.- Declare
router.attachments()instart/routes.tsto expose a read route. It allows onlydefaultDiskunlessdisksis specified. The formerrouteconfig key has been removed. See Serving files. queuecontrols where jobs run. Omit it for an in-memory queue, or use named connections and a typed default. Direct drivers, instances, and factories remain supported.jobHandleris an advanced override for memory processing. It takes precedence over the store's default processor. External forwarding jobs can usecreateAttachmentProcessor(app).
Next: Creating attachments.