@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 +28 -0
- package/package.json +11 -8
- package/skills/media-attachments-single-file/SKILL.md +224 -0
- package/skills/media-library-collections/SKILL.md +334 -0
- package/skills/media-setup/SKILL.md +253 -0
- package/skills/media-stores-delivery/SKILL.md +302 -0
- package/skills/media-uploads-resumable/SKILL.md +347 -0
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.
|
|
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": "
|
|
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": "
|
|
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
|
-
"@
|
|
137
|
-
"@adonis-agora/media-dashboard": "8.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
|
|
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.
|