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; 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>
}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.
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'], 'media_owner_idx')
table.index(['owner_type', 'owner_id', 'collection'], 'media_owner_collection_idx')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}To use it:
defineConfig({
store: 'lucid',
stores: { lucid: stores.lucid({ connection: 'pg' }) },
})node ace migration:runThe two indexes back the library's hot paths — listByOwner (owner, optionally + collection) and the single-file replacement check. connection defaults to Lucid's default; table defaults to media.
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 a one-method interface:
interface ImageProcessor {
convert(input: Buffer, preset: ConversionPreset): Promise<ConversionResult>
}
interface ConversionResult {
data: Buffer
format: string // actual output format/extension, e.g. 'webp'
contentType: string
}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);format→toFormat(format)— defaultwebp;quality→ the format'squalityoption.
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.