Stores & Processors
The three pluggable seams behind the library — the MediaStore (in-memory + Lucid, with a published migration), the ImageProcessor (sharp), and the Disk contract that reuses @adonisjs/drive. Plus how to write your own.
The media library is small on purpose: the heavy lifting is delegated to three pluggable seams, each a tiny interface (an SPI). This page covers all three — the persistence MediaStore, the ImageProcessor, and the Disk contract that lets the library reuse @adonisjs/drive — and how to swap any of them.
The Disk contract — reusing Drive
The library never imports @adonisjs/drive. Instead it defines a minimal structural Disk interface — a subset of a flydrive/Drive disk — and resolves disks through a DiskResolver. A real Drive disk satisfies the contract directly, so in production the resolver is simply (name) => drive.use(name); in tests it's a resolver over in-memory fakes.
interface Disk {
put(key: string, contents: Uint8Array, options?: DiskWriteOptions): Promise<void>
getBytes(key: string): Promise<Uint8Array>
getStream(key: string): Promise<Readable>
exists(key: string): Promise<boolean>
delete(key: string): Promise<void>
getUrl(key: string): Promise<string>
getSignedUrl(key: string, options?: SignedUrlOptions): Promise<string>
getMetaData(key: string): Promise<DiskMetaData>
// optional — present on every real Drive disk, absent on a minimal hand-rolled one
putStream?(key: string, contents: Readable, options?: DiskWriteOptions): Promise<void>
getVisibility?(key: string): Promise<'public' | 'private'>
}
type DiskResolver = (name?: string) => DiskTen methods, of which eight are required. The two optional ones are what a real Drive disk adds on top of the minimum, and each unlocks a specific behaviour rather than being decorative:
putStreamwrites a stream straight to the backend without buffering it in memory. The library uses it for a large upload that needs no conversions and whose size is known up front, and to assemble a resumable upload on a disk without native multipart; when the method is absent it falls back toput, buffering the whole payload. On S3 the size matters: a stream has no knowable length and the API needsContentLengthup front, which is whyDiskWriteOptions.contentLengthexists.getVisibilityanswers whether an object is readable without credentials. This is the methoddelivery.mode: 'auto'asks: public ⇒ hand out the stable public URL, otherwise ⇒ sign one. A disk that cannot answer is treated as private, soautodegrades tosignedrather than leaking a URL that would 403. See Delivery.
A couple more deliberate choices:
- Binary reads use
getBytes(aUint8Array) rather than Drive'sget(which returns a UTF-8 string) — images aren't text. - Writes accept a
Uint8Array, so the library buffers any incomingReadablefirst (viatoBytes). getMetaData().contentLengthis read back after a write when you don't pass an explicitsize, so records always carry a byte size.
Two further contracts sit off this interface on purpose, detected structurally at runtime rather than being required of every disk: MultipartUploadDisk (native server-side multipart, behind isMultipartCapable) and ExtendedDisk (copy/move/deleteMany/list/size/stat plus a capabilities descriptor, behind isExtendedDisk). The bundled disks.s3() driver implements both; the in-memory test disk and a plain Drive disk implement neither, and the library degrades instead of failing.
The StorageManager is the thin façade the library talks to — it wraps a DiskResolver plus a default disk name, and the media and attachment layers only ever go through it. This is why Drive stays a peer dependency and why the in-memory test disk is a true drop-in. You can reach the raw disk through media.disk(name?) as an escape hatch.
createDriveBackedResolver
The resolver the provider actually runs on is exported, so you can build a MediaLibrary outside AdonisJS — a script, a worker, a test against real Drive disks:
import { createDriveBackedResolver, StorageManager } from '@adonis-agora/media'
import * as driveService from '@adonisjs/drive/services/main'
const resolve = createDriveBackedResolver({
driveService, // the module NAMESPACE, not its default export
configuredDisks: { s3: myS3Disk }, // built from `config.disks` — these win by name
defaultDisk: 's3',
})
const storage = new StorageManager({ default: 's3', resolve })Two behaviours are load-bearing:
- Locally-configured disks win. A name found in
configuredDisksnever reaches Drive at all, which is what makesdiskstake precedence over a Drive disk of the same name — and what lets an app whose disks all come fromdisksnever touch Drive. - Drive is read lazily, at the first resolution that actually needs it, then memoized. That is what makes the resolver safe to construct during
register(), long before Drive has booted. Resolving a Drive disk before Drive is ready throwsDriveNotReadyErrorrather than aCannot read properties of undefineddeep inside a write.
Pass the module namespace (import * as driveService), not a captured default. Drive's service module assigns its manager inside an app.booted() callback, so at import time the export is still undefined; ESM live bindings mean the namespace's property reflects the later assignment, while a value destructured out of it is frozen at undefined forever.
Because the contract is structural, you aren't limited to Drive. Anything that implements the eight required methods works — though Drive is the intended (and zero-effort) backend, since every AdonisJS app already has it.
The MediaStore — persistence
The MediaStore persists the metadata rows the MediaLibrary produces. It's one small interface:
interface MediaStore {
save(record: MediaRecord): Promise<MediaRecord>
find(id: string): Promise<MediaRecord | null>
listByOwner(ownerType: string, ownerId: string, collection?: string): Promise<MediaRecord[]>
list(options?: MediaListOptions): Promise<MediaListPage>
delete(id: string): Promise<void>
nextOrder(ownerType: string, ownerId: string, collection: string): Promise<number>
collectionTotals?(): Promise<MediaCollectionTotals[]>
}listByOwner returns records ordered by order ascending, and nextOrder returns the next 0-based slot for appending — those two are the library's own hot paths.
list is the cross-owner read, and it is not optional. It takes a MediaListOptions (collection / ownerType / ownerId / prefix filters, all ANDed, plus cursor and limit) and returns a MediaListPage — { items, nextCursor }, where nextCursor is null on the last page. The ordering is a stable keyset: createdAt descending, then id descending, so paging stays consistent while rows are being written underneath it. Three helpers are exported for implementers: encodeMediaCursor / decodeMediaCursor (the cursor is base64url JSON of { createdAt, id }) and clampMediaListLimit (defaults to DEFAULT_MEDIA_LIST_LIMIT, caps at MAX_MEDIA_LIST_LIMIT, so a caller cannot request an unbounded scan).
Nothing inside attach/url/delete calls list — it exists for management views. It is what the dashboard browses stored records with, which is why a store that omits it leaves the console's collection views empty.
collectionTotals is optional: { collection, count, sumSize } per collection over the whole store, ordered by each collection's newest record first. The console's collection summary uses it when present (one aggregate) and pages through list when it is not.
In-memory store
stores.memory() builds an InMemoryMediaStore — a Map-backed store, single-process and non-durable. It's the default when no store is selected, and it's what the testing kit exposes. Great for tests and scratch apps; not for production (it forgets everything on restart and doesn't share across processes).
Lucid store
stores.lucid({ connection?, table? }) builds a LucidMediaStore that persists rows in SQL via @adonisjs/lucid's query builder (Knex), so it works across SQLite / Postgres / MySQL. @adonisjs/lucid is an optional peer imported lazily — only when you select lucid.
The schema is deliberately portable: JSON payloads (custom_properties, conversions) are stored as TEXT (the store (de)serializes them), and timestamps as epoch-ms integers. configure publishes the migration:
export default class extends BaseSchema {
protected tableName = 'media'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.string('id').primary()
table.string('owner_type').notNullable()
table.string('owner_id').notNullable()
table.string('collection').notNullable()
table.string('name').notNullable()
table.string('file_name').notNullable()
table.string('mime_type').notNullable()
table.bigInteger('size').notNullable()
table.string('disk').notNullable()
table.text('path').notNullable()
table.integer('order').notNullable().defaultTo(0)
table.text('custom_properties')
table.text('conversions')
table.bigInteger('created_at').notNullable()
table.bigInteger('updated_at').notNullable()
table.index(['owner_type', 'owner_id', 'collection'], 'media_owner_collection_idx')
table.index(['created_at', 'id'], 'media_created_idx')
table.index(['collection', 'created_at', 'id'], 'media_collection_created_idx')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}To use it:
defineConfig({
store: 'lucid',
stores: { lucid: stores.lucid({ connection: 'pg' }) },
})node ace migration:runThe indexes back the library's hot paths: the owner one serves listByOwner (owner, optionally + collection) and the single-file replacement check; the two created_at ones serve the newest-first list() pages the console reads, unfiltered and per collection. An owner-only index (owner_type, owner_id) would be redundant: those columns are the leftmost prefix of media_owner_collection_idx. A database that already has one (media_owner_idx) can keep it; nothing drops it. connection defaults to Lucid's default; table defaults to media.
With the default autoCreateSchema, the store adds any missing index on boot (CREATE INDEX IF NOT EXISTS). A schema managed by migrations (autoCreateSchema: false) created before the created_at indexes existed gets them from a migration of its own:
export default class extends BaseSchema {
async up() {
this.schema.alterTable('media', (table) => {
table.index(['created_at', 'id'], 'media_created_idx')
table.index(['collection', 'created_at', 'id'], 'media_collection_created_idx')
})
}
async down() {
this.schema.alterTable('media', (table) => {
table.dropIndex(['created_at', 'id'], 'media_created_idx')
table.dropIndex(['collection', 'created_at', 'id'], 'media_collection_created_idx')
})
}
}Import it directly from the subpath (@adonis-agora/media/stores/lucid) if you need the LucidMediaStore class — e.g. to construct one by hand in a test that uses a real database.
Writing your own store
A store is a POJO that implements the six methods. Receive whatever connection it needs in the constructor, and wire it via a StoreFactory thunk in config (so any peer it needs is imported lazily):
import type { MediaStore, StoreFactory } from '@adonis-agora/media'
class RedisMediaStore implements MediaStore {
// save / find / listByOwner / list / delete / nextOrder ...
}
// config/media.ts
defineConfig({
store: 'redis',
stores: {
redis: (() => async () => new RedisMediaStore(/* ... */)) as unknown as StoreFactory,
},
})A StoreFactory receives a StoreContext ({ app }), the booted application — so a driver can resolve a peer's service (e.g. the Lucid db) when it builds.
The ImageProcessor — conversions
The conversion engine is one required method, plus two optional ones that unlock features:
interface ImageProcessor {
convert(input: Buffer, preset: ConversionPreset, options?: ConvertOptions): Promise<ConversionResult>
/** Displayed size (EXIF applied). Enables `onlyDownscale` / `responsive` collections. */
dimensions?(input: Buffer): Promise<{ width: number; height: number } | undefined>
/** The image without EXIF/XMP/IPTC, same format. Enables `stripMetadata` collections. */
stripMetadata?(input: Buffer): Promise<Buffer | undefined>
}
interface ConvertOptions {
focalPoint?: { x: number; y: number } // the record's focal point, for cropping presets
}
interface ConversionResult {
data: Buffer
format: string // actual output format/extension, e.g. 'webp'
contentType: string
width?: number // output size — recorded in conversions[name].meta, what srcset is built from
height?: number
}A processor without dimensions makes onlyDownscale a no-op (every width is generated). One
without stripMetadata leaves originals untouched. And if it skips width/height,
media.library.image() falls back to deriving widths from the presets.
The sharp processor
processors.sharp() builds a SharpImageProcessor backed by sharp. sharp is an optional peer, imported lazily only when the processor is built. It maps the preset straight onto sharp:
width/height→resize({ width, height, fit })(only when a dimension is set);fit→ sharp'sfit(defaultcover);position→ sharp'sposition(a gravity, or theattention/entropystrategies);withoutEnlargement→ sharp'swithoutEnlargement;blur→blur(sigma), after the resize;format→toFormat(format)— defaultwebp;quality→ the format'squalityoption.
On top of that mapping:
- EXIF orientation is always applied (
rotate()) before anything else, so phone photos stay upright. - A focal point (
options.focalPoint) on a cropping preset (coverwith both sides) scales the image to cover the box and cuts the box around the point. It wins overposition. - Animated GIF/WebP keep every frame when the output is WebP. Other formats get the first frame, never the frames stacked into one tall image.
- The output size is reported (
width,height, with one frame's height for an animation). dimensions()reads the displayed size from the header, without decoding the image.stripMetadata()re-encodes JPEG, PNG, WebP, AVIF and TIFF without metadata, keeping the colour profile and baking the orientation into the pixels. An image with nothing to strip returnsundefined, so the original is untouched.
Import it directly from @adonis-agora/media/processors/sharp if you want the class, or select it lazily with processors.sharp() in config.
Writing your own processor
Implement convert and either pass an instance or a factory to imageProcessor. A processor that just passed bytes through, for example:
import type { ImageProcessor } from '@adonis-agora/media'
class PassthroughProcessor implements ImageProcessor {
async convert(input: Buffer, preset) {
return { data: input, format: preset.format ?? 'webp', contentType: 'image/webp' }
}
}
// config/media.ts — pass a ready instance directly
defineConfig({ imageProcessor: new PassthroughProcessor() })For tests, the kit's FakeImageProcessor is a deterministic, sharp-free implementation that records every call.
How it all composes
MediaManager
├─ StorageManager ──> DiskResolver ──> @adonisjs/drive disk (the Disk contract)
├─ MediaLibrary ────> MediaStore (in-memory | Lucid | yours)
│ └─> ImageProcessor (sharp | yours)
└─ AttachmentManager ─> same StorageManager + same ImageProcessorOne disk layer, one store, one processor — shared by both the library and the attachment manager. Swap any seam without touching the others.
Next steps
- Configuration — selecting stores and processors by name
- Testing — the in-memory doubles for all three seams
- Collections & Conversions — what the processor is asked to produce
Programmatic API
DashboardService, DashboardError, MediaManagerLike and the JSON contract types — the console's logic as a plain, framework-free object you can call from your own routes, plus the response shapes shared by server and SPA.
Telescope
mediaTelescopeExtension — a first-class @adonis-agora/telescope extension that adds a "Media" overview dashboard (uploads, storage operations, image conversions) built from the agora:media:* diagnostics events. Telescope stays an optional, never-imported peer.