@adonis-agora/media 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @adonis-agora/media
2
2
 
3
+ ## 0.12.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#57](https://github.com/DavideCarvalho/adonis-agora-media/pull/57) [`331f53f`](https://github.com/DavideCarvalho/adonis-agora-media/commit/331f53f6294f3b7e4261bdbf65fb8090063aa673) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Ship TanStack Intent agent skills with every package. Each package now publishes a
8
+ `skills/` directory (`media-*` SKILL.md files) that lands in `node_modules` on install, so
9
+ AI coding agents can discover them via `npx @tanstack/intent list`; adds `@tanstack/intent`
10
+ as a devDependency for `intent validate` in CI.
11
+
12
+ ## 0.12.1
13
+
14
+ ### Patch Changes
15
+
16
+ - [#52](https://github.com/DavideCarvalho/adonis-agora-media/pull/52) [`4d9651d`](https://github.com/DavideCarvalho/adonis-agora-media/commit/4d9651d29863f2823f426a38588305528e8da699) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - **Fixes a hard `npm install` failure.** Two peer ranges used a caret over a 0.x dependency, which under semver does not cross a minor — `^0.4.0` means `>=0.4.0 <0.5.0`. Every later minor of those peers fell out of range.
17
+
18
+ pnpm downgrades an unsatisfied peer to a warning, so this was invisible inside the monorepo. **npm treats it as `ERESOLVE` and refuses to install**, even though both peers are declared optional:
19
+
20
+ ```
21
+ While resolving: @adonis-agora/media@0.12.0
22
+ Found: @adonis-agora/telescope@0.8.1
23
+ Conflicting peer dependency: @adonis-agora/telescope@0.4.0
24
+ ```
25
+
26
+ - `@adonis-agora/telescope`: `^0.4.0` → `>=0.4.0 <1.0.0`. Telescope is at 0.8.1, so any consumer on a current telescope could not install this package under npm. The floor stays at 0.4.0: bisecting the published tarballs shows the extension contract `mediaTelescopeExtension()` implements has been type-compatible since 0.1.0, so 0.4.0 was never too high — only the ceiling was wrong.
27
+ - `sharp`: `^0.33.0` → `>=0.33.0 <1.0.0`. Same defect, same `ERESOLVE`; sharp is at 0.35.3. The processor only uses `resize`/`toFormat`/`quality`/`toBuffer`, verified end to end against sharp 0.35.3 outside the monorepo.
28
+
29
+ No runtime behaviour changes, and no dependency is bumped — this only widens what a consumer is _allowed_ to have installed.
30
+
3
31
  ## 0.12.0
4
32
 
5
33
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/media",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Media-library for AdonisJS — owner collections (MIME whitelist, single-file replace, ordering), image conversions (eager/lazy), pluggable content transformers (HLS video via mediabunny, metadata probe), and column attachments, all on top of @adonisjs/drive. Pluggable MediaStore (in-memory + Lucid) and ImageProcessor (sharp). Part of the Agora ecosystem.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -87,17 +87,18 @@
87
87
  "files": [
88
88
  "dist/",
89
89
  "README.md",
90
- "CHANGELOG.md"
90
+ "CHANGELOG.md",
91
+ "skills"
91
92
  ],
92
93
  "sideEffects": false,
93
94
  "peerDependencies": {
94
- "@adonis-agora/telescope": "^0.4.0",
95
+ "@adonis-agora/telescope": ">=0.4.0 <1.0.0",
95
96
  "@adonisjs/core": "^7.3.0",
96
97
  "@adonisjs/drive": "^3.0.0 || ^4.0.0",
97
98
  "@adonisjs/lucid": "^22.4.0",
98
99
  "@aws-sdk/client-s3": "^3.0.0",
99
100
  "mediabunny": "^1.45.4",
100
- "sharp": "^0.33.0"
101
+ "sharp": ">=0.33.0 <1.0.0"
101
102
  },
102
103
  "peerDependenciesMeta": {
103
104
  "@adonis-agora/telescope": {
@@ -133,8 +134,9 @@
133
134
  "tempura": "0.4.1",
134
135
  "typescript": "^5.4.0",
135
136
  "vitest": "3.2.7",
136
- "@adonis-agora/media-react": "0.5.0",
137
- "@adonis-agora/media-dashboard": "8.0.0"
137
+ "@tanstack/intent": "^0.3.2",
138
+ "@adonis-agora/media-dashboard": "8.0.1",
139
+ "@adonis-agora/media-react": "0.5.1"
138
140
  },
139
141
  "engines": {
140
142
  "node": ">=20.6.0"
@@ -155,11 +157,12 @@
155
157
  "mediabunny",
156
158
  "sharp",
157
159
  "attachment",
158
- "agora"
160
+ "agora",
161
+ "tanstack-intent"
159
162
  ],
160
163
  "scripts": {
161
164
  "build": "tsc -p tsconfig.json && pnpm run copy:stubs",
162
- "copy:stubs": "mkdir -p dist/stubs/config dist/stubs/database/migrations dist/assets && cp stubs/config/media.stub dist/stubs/config/media.stub && cp stubs/config/media_dashboard.stub dist/stubs/config/media_dashboard.stub && cp -r stubs/database/migrations/. dist/stubs/database/migrations/ && rm -rf dist/assets/spa && cp -r ../dashboard/dist/spa dist/assets/spa",
165
+ "copy:stubs": "mkdir -p dist/stubs/config dist/stubs/database/migrations dist/assets && cp -r stubs/config/. dist/stubs/config/ && cp -r stubs/database/migrations/. dist/stubs/database/migrations/ && rm -rf dist/assets/spa && cp -r ../dashboard/dist/spa dist/assets/spa",
163
166
  "test": "vitest run --passWithNoTests",
164
167
  "test:watch": "vitest",
165
168
  "typecheck": "tsc -p tsconfig.json --noEmit"
@@ -0,0 +1,224 @@
1
+ ---
2
+ name: media-attachments-single-file
3
+ description: >-
4
+ Column attachments and the single-file seam in @adonis-agora/media —
5
+ AttachmentManager.createFromFile with always-eager image variants, the Attachment
6
+ value object (toJSON / Attachment.fromJSON), persisting to a Lucid JSON column with
7
+ prepare/consume, media.attachments.url/signedUrl/delete semantics, and
8
+ @adonis-agora/media/single-file (storeSingleFile, removeSingleFile,
9
+ isSingleFileStoreAvailable, storeSingleFileWith) so other packages can delegate
10
+ avatar-style uploads without a hard dependency. Use when a model "has a file"
11
+ (avatar, hero image, logo) or when wiring cross-package single-file uploads.
12
+ license: MIT
13
+ metadata:
14
+ type: core
15
+ library: "@adonis-agora/media"
16
+ library_version: "0.12.1"
17
+ framework: adonisjs
18
+ sources:
19
+ - "DavideCarvalho/adonis-media:docs/attachments.mdx"
20
+ - "DavideCarvalho/adonis-media:docs/single-file.mdx"
21
+ - "DavideCarvalho/adonis-media:packages/adonis/src/attachment.ts"
22
+ - "DavideCarvalho/adonis-media:packages/adonis/src/single_file_store.ts"
23
+ ---
24
+
25
+ # Column attachments & the single-file seam
26
+
27
+ Two shapes share one storage layer. The `MediaLibrary` is owner+collections; the
28
+ `AttachmentManager` is the adonis-attachment style — one file living **directly on a model
29
+ column** as a JSON value, no owner row, no extra table. Reach for an attachment when a model
30
+ simply *has* a file (an avatar, a hero image); reach for collections when an entity has many
31
+ files with policy.
32
+
33
+ ## Setup
34
+
35
+ ```ts title="app/controllers/avatars_controller.ts"
36
+ import { createReadStream } from 'node:fs'
37
+ import User from '#models/user'
38
+ import media from '@adonis-agora/media/services/main'
39
+ import type { HttpContext } from '@adonisjs/core/http'
40
+
41
+ export default class AvatarsController {
42
+ async update({ request, response, auth }: HttpContext) {
43
+ const user = auth.getUserOrFail()
44
+
45
+ const avatar = request.file('avatar', { size: '2mb', extnames: ['jpg', 'jpeg', 'png', 'webp'] })
46
+ if (!avatar) return response.badRequest({ error: 'An "avatar" file is required' })
47
+
48
+ const att = await media.attachments.createFromFile(
49
+ {
50
+ fileName: avatar.clientName,
51
+ mimeType: avatar.type!,
52
+ contents: createReadStream(avatar.tmpPath!), // Buffer | Readable
53
+ },
54
+ { variants: [{ name: 'thumb', width: 100 }] },
55
+ )
56
+
57
+ user.avatarData = att.toJSON() // store on a JSON column
58
+ await user.save()
59
+
60
+ return response.ok(user)
61
+ }
62
+ }
63
+ ```
64
+
65
+ The file lands at `${keyPrefix}/${id}/${fileName}` (`keyPrefix` defaults to your
66
+ `attachmentKeyPrefix`, default `attachments`), variants at
67
+ `${keyPrefix}/${id}/variants/${name}.${format}`.
68
+
69
+ Source: `docs/attachments.mdx` (Creating an attachment).
70
+
71
+ ## Core patterns
72
+
73
+ ### Pattern 1 — serialize onto a Lucid JSON column
74
+
75
+ An `Attachment` is a pure JSON-serializable value object (disk, path, size, mimeType,
76
+ variants, meta). Persist `toJSON()`, rebuild with `Attachment.fromJSON()`:
77
+
78
+ ```ts title="app/models/user.ts"
79
+ import { BaseModel, column } from '@adonisjs/lucid/orm'
80
+ import { Attachment, type AttachmentData } from '@adonis-agora/media'
81
+
82
+ export default class User extends BaseModel {
83
+ @column({
84
+ prepare: (v) => (v ? JSON.stringify(v) : v),
85
+ consume: (v) => (typeof v === 'string' ? JSON.parse(v) : v),
86
+ })
87
+ declare avatarData: AttachmentData | null
88
+
89
+ get avatar(): Attachment | null {
90
+ return Attachment.fromJSON(this.avatarData)
91
+ }
92
+ }
93
+ ```
94
+
95
+ Source: `docs/attachments.mdx` (A model-column pattern).
96
+
97
+ ### Pattern 2 — resolve URLs and signed URLs through the manager
98
+
99
+ The value object never touches storage; URL resolution and deletion go through
100
+ `media.attachments`:
101
+
102
+ ```ts
103
+ const att = Attachment.fromJSON(user.avatarData)!
104
+
105
+ await media.attachments.url(att) // original
106
+ await media.attachments.url(att, 'thumb') // named variant
107
+
108
+ await media.attachments.signedUrl(att, '30m') // expiring (s3/gcs)
109
+ await media.attachments.signedUrl(att, '1h', { variant: 'thumb' }) // signed variant
110
+
111
+ // force a named download — headers are baked into the URL by the presigner
112
+ await media.attachments.signedUrl(att, '1h', {
113
+ contentDisposition: `attachment; filename="${att.name}"`,
114
+ })
115
+ ```
116
+
117
+ Source: `docs/attachments.mdx` (Resolving URLs).
118
+
119
+ ### Pattern 3 — the single-file store for other packages
120
+
121
+ `@adonis-agora/media/single-file` is the narrow seam for "replace this owner's one file and
122
+ give me a URL", importable unconditionally with feature detection:
123
+
124
+ ```ts
125
+ import { isSingleFileStoreAvailable, removeSingleFile, storeSingleFile } from '@adonis-agora/media/single-file'
126
+
127
+ if (await isSingleFileStoreAvailable()) {
128
+ const { url, thumbUrl } = await storeSingleFile({
129
+ ownerType: 'User',
130
+ ownerId: String(user.id),
131
+ collection: 'avatar',
132
+ fileName: upload.clientName,
133
+ mimeType: upload.type!,
134
+ contents: upload.bytes, // a Buffer
135
+ })
136
+ // thumbUrl is null — not an error — when the 'thumb' conversion isn't configured
137
+ }
138
+
139
+ // tests or multi-manager apps pass the manager explicitly:
140
+ import { storeSingleFileWith } from '@adonis-agora/media/single-file'
141
+ await storeSingleFileWith(testManager, { ownerType: 'User', ownerId: '1', collection: 'avatar',
142
+ fileName: 'me.png', mimeType: 'image/png', contents: pngBytes })
143
+ ```
144
+
145
+ `removeSingleFile({ ownerType, ownerId, collection })` empties the owner's collection,
146
+ deleting each record's disk object and conversions.
147
+
148
+ Source: `docs/single-file.mdx`.
149
+
150
+ ## Common mistakes
151
+
152
+ ### [HIGH] Requesting attachment variants that were not declared up front
153
+
154
+ Wrong:
155
+
156
+ ```ts
157
+ const att = await media.attachments.createFromFile(input) // no variants option
158
+ await media.attachments.url(att, 'thumb') // throws
159
+ ```
160
+
161
+ Correct:
162
+
163
+ ```ts
164
+ const att = await media.attachments.createFromFile(input, {
165
+ variants: [{ name: 'thumb', width: 100 }],
166
+ })
167
+ await media.attachments.url(att, 'thumb')
168
+ ```
169
+
170
+ Mechanism: unlike the library's lazy conversions, attachment **variants are always eager** —
171
+ generated inside `createFromFile`, because there is no per-attachment record to cache a
172
+ lazily-generated variant onto. An undeclared variant throws `VariantNotFoundError`
173
+ (`E_MEDIA_VARIANT_NOT_FOUND`). Requesting any variant with no `imageProcessor` configured
174
+ throws.
175
+ Source: `docs/attachments.mdx` (Variants are eager callout, Resolving URLs).
176
+
177
+ ### [HIGH] Leaving the model column populated after attachments.delete()
178
+
179
+ Wrong:
180
+
181
+ ```ts
182
+ await media.attachments.delete(att)
183
+ // column still holds the JSON → every url()/signedUrl() resolves deleted objects
184
+ ```
185
+
186
+ Correct:
187
+
188
+ ```ts
189
+ await media.attachments.delete(att)
190
+ user.avatarData = null
191
+ await user.save()
192
+ ```
193
+
194
+ Mechanism: `delete()` removes the original AND every variant from storage but deliberately
195
+ does not touch your model column — clearing it is yours, since only you know the field.
196
+ Stale columns keep producing dead URLs until saved as null.
197
+ Source: `docs/attachments.mdx` (Deleting).
198
+
199
+ ### [CRITICAL] Calling storeSingleFile against a collection that is not single:true
200
+
201
+ Wrong:
202
+
203
+ ```ts
204
+ // config declares NO 'avatar' collection (or declares it without single: true)
205
+ await storeSingleFile({ ownerType: 'User', ownerId: '1', collection: 'avatar', ... }) // upload #1
206
+ await storeSingleFile({ ownerType: 'User', ownerId: '1', collection: 'avatar', ... }) // upload #2
207
+ ```
208
+
209
+ Correct:
210
+
211
+ ```ts
212
+ // config/media.ts
213
+ defineConfig({
214
+ collections: [{ name: 'avatar', single: true, acceptsMimeTypes: ['image/png', 'image/jpeg'] }],
215
+ })
216
+ ```
217
+
218
+ Mechanism: nothing about these functions enforces replacement — replacement is a property of
219
+ the COLLECTION. Without `single: true` each call appends another record, and "the avatar"
220
+ quietly becomes a growing list of files.
221
+ Source: `docs/single-file.mdx` (warn callout: `single: true` lives in the config).
222
+
223
+ See also: `media-library-collections/SKILL.md` — choosing between the two shapes and the
224
+ collection policy both paths run under.
@@ -0,0 +1,334 @@
1
+ ---
2
+ name: media-library-collections
3
+ description: >-
4
+ Attach files to owner collections with @adonis-agora/media — media.library.attach /
5
+ attachExisting / for(ownerType, ownerId), the MediaCollectionConfig policy layer
6
+ (acceptsMimeTypes exact-match whitelist plus magic-byte signature verification,
7
+ single:true atomic replace, append-only ordering, per-collection disk), eager vs lazy
8
+ ConversionPresets (width/height/fit/format/quality, webp default), ensureConversion,
9
+ url/signedUrl resolution, delete semantics, deterministic id retries, customProperties,
10
+ and the MediaRecord.conversions map shared with transformers. Use when uploading files
11
+ to entities, defining collections, generating thumbnails, or fixing
12
+ MimeNotAllowedError / ConversionNotDefinedError / ImageProcessorMissingError /
13
+ TransformNotReadyError.
14
+ license: MIT
15
+ metadata:
16
+ type: core
17
+ library: "@adonis-agora/media"
18
+ library_version: "0.12.1"
19
+ framework: adonisjs
20
+ sources:
21
+ - "DavideCarvalho/adonis-media:docs/getting-started.mdx"
22
+ - "DavideCarvalho/adonis-media:docs/collections-and-conversions.mdx"
23
+ - "DavideCarvalho/adonis-media:docs/transformers.mdx"
24
+ - "DavideCarvalho/adonis-media:packages/adonis/src/media_library.ts"
25
+ ---
26
+
27
+ # Media library: collections, policy, and conversions
28
+
29
+ `media.library` is the spatie/laravel-media-library shape: files belong to an owner
30
+ (`ownerType` + `ownerId`, both just stored strings — the library never loads your models)
31
+ inside named collections that carry the policy. Attach writes bytes to a Drive disk and a
32
+ metadata record to the configured `MediaStore`.
33
+
34
+ ## Setup
35
+
36
+ ```ts title="app/controllers/post_images_controller.ts"
37
+ import { createReadStream } from 'node:fs'
38
+ import Post from '#models/post'
39
+ import media from '@adonis-agora/media/services/main'
40
+ import type { HttpContext } from '@adonisjs/core/http'
41
+
42
+ export default class PostImagesController {
43
+ async store({ request, response, params }: HttpContext) {
44
+ const post = await Post.findOrFail(params.id)
45
+
46
+ const image = request.file('image', { size: '5mb', extnames: ['jpg', 'png', 'webp'] })
47
+ if (!image) return response.badRequest({ error: 'An "image" file is required' })
48
+
49
+ const record = await media.library.attach({
50
+ ownerType: 'Post',
51
+ ownerId: post.id, // string | number — coerced for you
52
+ collection: 'gallery',
53
+ fileName: image.clientName,
54
+ mimeType: image.type!,
55
+ contents: createReadStream(image.tmpPath!), // Buffer | Readable
56
+ })
57
+
58
+ return response.created({ id: record.id, url: await media.library.url(record.id) })
59
+ }
60
+ }
61
+ ```
62
+
63
+ The file lands at `${ownerType}/${ownerId}/${collection}/${id}/${fileName}` on the resolved
64
+ disk (per-call `disk` → collection `disk` → storage default). Eager conversions are
65
+ generated synchronously inside `attach`; lazy ones wait for the first `url(id, name)`.
66
+
67
+ Source: `docs/getting-started.mdx` (Step 3).
68
+
69
+ ## Core patterns
70
+
71
+ ### Pattern 1 — declare the collection policy in config
72
+
73
+ Collections are opt-in constraints: an undeclared collection is permissive (no whitelist,
74
+ not single, no conversions). Declare one to constrain it:
75
+
76
+ ```ts title="config/media.ts"
77
+ defineConfig({
78
+ collections: [
79
+ { name: 'avatar', single: true, acceptsMimeTypes: ['image/png', 'image/jpeg'] },
80
+ { name: 'invoices', disk: 'private-s3', acceptsMimeTypes: ['application/pdf'] },
81
+ {
82
+ name: 'gallery',
83
+ conversions: [
84
+ { name: 'thumb', width: 200, height: 200, fit: 'cover' },
85
+ { name: 'card', width: 600, format: 'webp', quality: 80 },
86
+ { name: 'og', width: 1200, height: 630, eager: true },
87
+ ],
88
+ },
89
+ ],
90
+ })
91
+ ```
92
+
93
+ `acceptsMimeTypes` is checked **before anything is written** (no orphan files on rejection)
94
+ and is an exact string match — list each type. When a whitelist is present the library also
95
+ verifies the file's magic-byte signature: a mismatch throws `ContentTypeMismatchError`, and
96
+ on a **closed** whitelist (every accepted type is signature-detectable, e.g.
97
+ `['application/pdf']`) unrecognizable bytes throw `ContentSignatureUnrecognizedError`. An
98
+ **open** whitelist (any type without a signature, e.g. `['application/pdf', 'image/svg+xml']`)
99
+ falls back to the declared type.
100
+
101
+ Source: `docs/collections-and-conversions.mdx`.
102
+
103
+ ### Pattern 2 — eager vs lazy conversions
104
+
105
+ `ConversionPreset` maps straight onto sharp. `eager: true` generates synchronously on
106
+ `attach`; the default is lazy — generated on the **first** `url(id, name)` and then cached on
107
+ the record, so every later call is a pure lookup.
108
+
109
+ ```ts
110
+ const m = await media.library.attach({ ...input, collection: 'gallery' })
111
+
112
+ await media.library.url(m.id, 'og') // eager → already on disk, pure lookup
113
+ await media.library.url(m.id, 'thumb') // lazy → generated NOW, then cached
114
+ await media.library.ensureConversion(m.id, 'thumb') // force it ahead of time (idempotent)
115
+ ```
116
+
117
+ Conversions are written under `conversions/` next to the original, recorded on
118
+ `record.conversions` keyed by preset name, and removed by `delete()` together with the
119
+ original. `format` defaults to `webp`; `fit` defaults to `cover`.
120
+
121
+ Source: `docs/collections-and-conversions.mdx` (Eager vs lazy).
122
+
123
+ ### Pattern 3 — bind an owner once with `for()`, adopt existing objects with `attachExisting`
124
+
125
+ ```ts
126
+ const gallery = media.library.for('Post', post.id)
127
+
128
+ for (const image of images) {
129
+ await gallery.attach({ collection: 'gallery', fileName: image.clientName, mimeType: image.type!, contents })
130
+ }
131
+
132
+ // zero-copy adoption of an object ALREADY on the disk (finished TUS / direct upload):
133
+ await gallery.attachExisting({
134
+ collection: 'gallery',
135
+ key: 'uploads/9f2a/clip.mp4',
136
+ fileName: 'clip.mp4',
137
+ mimeType: 'video/mp4',
138
+ })
139
+ ```
140
+
141
+ `attachExisting` never reads or rewrites bytes; the size comes from a disk HEAD when
142
+ omitted, and the collection's MIME + signature checks still run against a short head read.
143
+ Pass `moveIntoLayout: true` to relocate into the canonical layout using the disk's native
144
+ server-side move.
145
+
146
+ Source: `docs/getting-started.mdx` (Bind an owner once), `docs/uploads/resumable-tus.mdx`.
147
+
148
+ ### Pattern 4 — deterministic `id` for retry-safe overwrites
149
+
150
+ ```ts
151
+ await media.library.attach({
152
+ ownerType: 'Invoice',
153
+ ownerId: invoice.id,
154
+ collection: 'pdf',
155
+ id: `invoice-${invoice.id}`, // fixed key → retry-safe overwrite, no orphaned UUIDs
156
+ fileName: 'invoice.pdf',
157
+ mimeType: 'application/pdf',
158
+ contents: renderedPdf,
159
+ })
160
+ ```
161
+
162
+ The `id` is used verbatim as the record key and the storage-key segment — you own
163
+ uniqueness. Retries overwrite in place instead of minting a fresh UUID path.
164
+
165
+ Source: `docs/getting-started.mdx` (Idempotent overwrites).
166
+
167
+ ## Common mistakes
168
+
169
+ ### [CRITICAL] Assuming attach replaces an existing file without single:true
170
+
171
+ Wrong:
172
+
173
+ ```ts
174
+ // 'avatar' declared WITHOUT single: true — each call appends another file
175
+ await media.library.attach({ ownerType: 'User', ownerId: '1', collection: 'avatar', ... }) // v1
176
+ await media.library.attach({ ownerType: 'User', ownerId: '1', collection: 'avatar', ... }) // v2
177
+ await media.library.list('User', '1', 'avatar') // [v1, v2] — "the avatar" is now a list
178
+ ```
179
+
180
+ Correct:
181
+
182
+ ```ts
183
+ // config/media.ts
184
+ defineConfig({ collections: [{ name: 'avatar', single: true, acceptsMimeTypes: ['image/png'] }] })
185
+ ```
186
+
187
+ Mechanism: replacement is a property of the **collection**, not the call. `single: true`
188
+ deletes the previous file (original + conversions) only AFTER the new one is safely
189
+ persisted, so a failed upload leaves the old media intact; without it ordering is
190
+ append-only and every attach takes the next slot.
191
+ Source: `docs/collections-and-conversions.mdx` (Single-file collections), `docs/single-file.mdx` (warn callout).
192
+
193
+ ### [HIGH] Using wildcard MIME types in acceptsMimeTypes
194
+
195
+ Wrong:
196
+
197
+ ```ts
198
+ defineConfig({
199
+ collections: [{ name: 'images', acceptsMimeTypes: ['image/*'] }],
200
+ })
201
+ ```
202
+
203
+ Correct:
204
+
205
+ ```ts
206
+ defineConfig({
207
+ collections: [{ name: 'images', acceptsMimeTypes: ['image/png', 'image/jpeg', 'image/webp'] }],
208
+ })
209
+ ```
210
+
211
+ Mechanism: the whitelist is an exact string match against the declared `mimeType` — there is
212
+ no wildcard expansion, so `'image/*'` matches nothing and every attach throws
213
+ `MimeNotAllowedError` (`E_MEDIA_MIME_NOT_ALLOWED`) before anything is written.
214
+ Source: `docs/collections-and-conversions.mdx` (MIME whitelist).
215
+
216
+ ### [HIGH] Requesting conversions with no imageProcessor configured
217
+
218
+ Wrong:
219
+
220
+ ```ts
221
+ defineConfig({
222
+ collections: [{ name: 'gallery', conversions: [{ name: 'thumb', width: 200 }] }],
223
+ // no imageProcessor
224
+ })
225
+ ```
226
+
227
+ Correct:
228
+
229
+ ```ts
230
+ import { defineConfig, processors } from '@adonis-agora/media'
231
+
232
+ defineConfig({
233
+ imageProcessor: processors.sharp(),
234
+ collections: [{ name: 'gallery', conversions: [{ name: 'thumb', width: 200 }] }],
235
+ })
236
+ ```
237
+
238
+ Mechanism: `imageProcessor` is required only when conversions exist, but asking for one
239
+ without a configured processor throws `ImageProcessorMissingError`
240
+ (`E_MEDIA_IMAGE_PROCESSOR_MISSING`) instead of silently skipping.
241
+ Source: `docs/configuration.mdx` (`imageProcessor`), `docs/errors.mdx`.
242
+
243
+ ### [CRITICAL] Reading url() on a transformer conversion expecting lazy generation
244
+
245
+ Wrong:
246
+
247
+ ```ts
248
+ // 'videos' collection declares transformers.hls()
249
+ const url = await media.library.url(videoId, 'hls') // throws before any transform ran
250
+ ```
251
+
252
+ Correct:
253
+
254
+ ```ts
255
+ // run the transform explicitly (usually from a job), then read
256
+ await media.library.transform(videoId, 'hls') // idempotent — retries skip through
257
+ const record = await media.library.find(videoId)
258
+ record!.conversions.hls // { path, prefix, files, meta }
259
+ ```
260
+
261
+ Mechanism: image presets generate lazily on first read, but reads NEVER generate transforms —
262
+ `url()`/`deliver()` throw `TransformNotReadyError` so a request never stalls behind a remux.
263
+ The split is deliberate.
264
+ Source: `docs/transformers.mdx` (Reads never generate a transform).
265
+
266
+ ### [HIGH] Declaring conversion presets on a non-image collection
267
+
268
+ Wrong:
269
+
270
+ ```ts
271
+ defineConfig({
272
+ collections: [
273
+ { name: 'videos', acceptsMimeTypes: ['video/mp4'], conversions: [{ name: 'thumb', width: 320 }] },
274
+ ],
275
+ })
276
+ ```
277
+
278
+ Correct:
279
+
280
+ ```ts
281
+ import { defineConfig, transformers } from '@adonis-agora/media'
282
+
283
+ defineConfig({
284
+ collections: [
285
+ {
286
+ name: 'videos',
287
+ acceptsMimeTypes: ['video/mp4'],
288
+ transformers: [transformers.probe({ eager: true })], // non-image derivations
289
+ },
290
+ ],
291
+ })
292
+ ```
293
+
294
+ Mechanism: presets decode the original as an image through sharp; requesting one on a video
295
+ or PDF fails inside sharp. Transformers are the seam for everything beyond images and
296
+ persist into the same `record.conversions` namespace (name collisions throw
297
+ `TransformerConflictError` at boot).
298
+ Source: `docs/collections-and-conversions.mdx` (warn callout), `docs/transformers.mdx`.
299
+
300
+ ### [HIGH] Trusting the declared mimeType as the real content type
301
+
302
+ Wrong:
303
+
304
+ ```ts
305
+ // client renames payload.png → report.pdf and declares 'application/pdf'
306
+ await media.library.attach({
307
+ ownerType: 'Patient', ownerId: '7', collection: 'exams',
308
+ fileName: 'scan.pdf', mimeType: 'application/pdf', contents, // bytes are PNG
309
+ })
310
+ ```
311
+
312
+ Correct:
313
+
314
+ ```ts
315
+ try {
316
+ await media.library.attach({ ownerType: 'Patient', ownerId: '7', collection: 'exams',
317
+ fileName: 'scan.pdf', mimeType: 'application/pdf', contents })
318
+ } catch (error) {
319
+ if (error instanceof ContentTypeMismatchError) {
320
+ // error.declaredMimeType vs error.detectedMimeType — reject with a 4xx
321
+ }
322
+ throw error
323
+ }
324
+ ```
325
+
326
+ Mechanism: with `acceptsMimeTypes` present the library reads the file's magic-byte signature
327
+ (first 189 bytes, replayed for streams) and validates the REAL content — a renamed file is a
328
+ `ContentTypeMismatchError` even though the declared type is whitelisted. Only the head is
329
+ read; the payload stays streaming.
330
+ Source: `docs/collections-and-conversions.mdx` (The content is checked too).
331
+
332
+ See also: `media-attachments-single-file/SKILL.md` — the column-attachment shape for
333
+ single-file fields; `media-uploads-resumable/SKILL.md` — landing large uploads as records
334
+ via `attachExisting`; `media-stores-delivery/SKILL.md` — serving with delivery modes.