@zerotal/media 1.3.0

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 ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog — @zerotal/media
2
+
3
+ All notable changes to this package are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/); this package
5
+ follows the Zerotal monorepo's unified versioning.
6
+
7
+ **Maturity: `experimental`**
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [1.3.0] — 2026-08-09
12
+
13
+ ### Added
14
+
15
+ - Initial release. Attach files to models with the `Media` mixin —
16
+ `class Product extends Model.using(Media)` — declaring collections
17
+ declaratively on the model, and store originals on any configured disk. One
18
+ stored file is a `MediaItem`.
19
+ - Media collections with `accepts` (checked against sniffed bytes), `maxSize`,
20
+ `single`, `onlyKeepLatest`, `fallbackUrl` / `fallbackPath`, and per-collection
21
+ disk overrides.
22
+ - Image conversions on `Bun.Image` — no native module required. `fit: "cover"`
23
+ is unavailable on the default driver and raises `UnsupportedManipulationError`
24
+ naming the fix; install `sharp` and set `driver: "sharp"` for cropping.
25
+ - Responsive image ladders with `srcset()` and a ThumbHash inline placeholder.
26
+ - Arbitrary per-item metadata via `withCustomProperties()` on the adder and
27
+ `getCustomProperty()` / `setCustomProperty()` / `forgetCustomProperty()` on the
28
+ item, round-tripped through the row's JSON column. Reading without a fallback
29
+ yields `unknown` (narrow it yourself); reading with one yields the fallback's
30
+ type and never `undefined`.
31
+ - Queued conversions through `@zerotal/queue` when it is registered, falling
32
+ back to inline generation when it is not.
33
+ - `media` table provisioned at boot by `mediaSchemaConcern`, so apps write no
34
+ migration.
35
+ - `MediaFake` assertions, `media:clean` and `media:regenerate` commands.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zerotal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @zerotal/media
2
+
3
+ > Attach files to models — collections, image conversions, responsive images, and ordering.
4
+
5
+ Associate uploads with any model, store them on any disk, and generate derived
6
+ images without installing a native module. Zerotal's answer to
7
+ `spatie/laravel-medialibrary`.
8
+
9
+ Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ bun add @zerotal/media
15
+ ```
16
+
17
+ ## Setup
18
+
19
+ Register the provider in `bootstrap/providers.ts` — alongside `StorageProvider`,
20
+ which media writes through:
21
+
22
+ ```ts
23
+ import { StorageProvider } from "@zerotal/core/storage";
24
+ import { MediaProvider } from "@zerotal/media";
25
+
26
+ export default [DatabaseProvider, StorageProvider, MediaProvider];
27
+ ```
28
+
29
+ No migration is needed: the `media` table is provisioned at boot, once, only
30
+ when missing.
31
+
32
+ ## Usage
33
+
34
+ Compose `Media` and declare the collections a model owns:
35
+
36
+ ```ts
37
+ import { Model, column } from "@zerotal/orm";
38
+ import { Media, type MediaCollections } from "@zerotal/media";
39
+
40
+ export class Product extends Model.using(Media) {
41
+ @column() name!: string;
42
+
43
+ static override mediaCollections: MediaCollections = {
44
+ images: {
45
+ accepts: ["image/jpeg", "image/png"],
46
+ conversions: { thumb: { width: 200, height: 200, format: "webp" } },
47
+ responsive: true,
48
+ },
49
+ };
50
+ }
51
+ ```
52
+
53
+ Then add and read files:
54
+
55
+ ```ts
56
+ await product.addMedia(await ctx.file("photo")).toCollection("images");
57
+
58
+ await product.getFirstMediaUrl("images", "thumb");
59
+ await product.getMedia("images");
60
+ await product.clearMediaCollection("images");
61
+ ```
62
+
63
+ ## Notable behaviour
64
+
65
+ - **Types come from bytes.** `accepts` is checked against the type sniffed from
66
+ the file's own contents, never the filename or the upload's `Content-Type`.
67
+ - **No native dependency.** Conversions run on `Bun.Image`, built into the
68
+ runtime. JPEG, PNG and WebP work on every host.
69
+ - **`Bun.Image` cannot crop.** `fit: "cover"` throws rather than silently
70
+ returning a stretched image. Install `sharp` and set `driver: "sharp"` if you
71
+ need centre-cropped thumbnails.
72
+ - **Deleting a model deletes its files** — unless it soft-deletes, in which case
73
+ they wait for `forceDelete()`.
74
+ - **Paths are keyed on uuid,** not the numeric id, so a public URL discloses
75
+ nothing about row counts.
76
+
77
+ ## Commands
78
+
79
+ ```bash
80
+ bun zt media:clean [--force]
81
+ bun zt media:regenerate [--model=Product] [--only=thumb,hero]
82
+ ```
83
+
84
+ ## Documentation
85
+
86
+ Full guide: [Media Library](https://zerotal.dev/docs/media).
87
+
88
+ ## License
89
+
90
+ MIT
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@zerotal/media",
3
+ "version": "1.3.0",
4
+ "license": "MIT",
5
+ "maturity": "experimental",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./commands": "./src/commands/index.ts"
13
+ },
14
+ "files": [
15
+ "CHANGELOG.md",
16
+ "src",
17
+ "!src/**/*.test.ts",
18
+ "!src/**/*.test.tsx",
19
+ "!src/**/*.spec.ts",
20
+ "!src/**/__fixtures__/**"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "engines": {
26
+ "bun": ">=1.3.14"
27
+ },
28
+ "scripts": {
29
+ "test": "bun test",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "@zerotal/core": "1.3.0",
34
+ "@zerotal/orm": "1.3.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@zerotal/queue": "^1.0.0",
38
+ "sharp": "^0.33 || ^0.34"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "@zerotal/queue": {
42
+ "optional": true
43
+ },
44
+ "sharp": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "devDependencies": {
49
+ "@zerotal/queue": "1.3.0",
50
+ "typescript": "^5.8.0"
51
+ },
52
+ "description": "Attach files to models: media collections, image conversions, responsive images, and ordering — on any Zerotal storage disk.",
53
+ "keywords": [
54
+ "zerotal",
55
+ "bun",
56
+ "typescript",
57
+ "framework",
58
+ "media",
59
+ "uploads",
60
+ "images"
61
+ ],
62
+ "repository": {
63
+ "type": "git",
64
+ "url": "git+https://github.com/zerotaldev/zerotal.git",
65
+ "directory": "packages/media"
66
+ },
67
+ "homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/media#readme",
68
+ "bugs": "https://github.com/zerotaldev/zerotal/issues"
69
+ }
package/src/Media.ts ADDED
@@ -0,0 +1,278 @@
1
+ import type { Constructor, ModelQueryBuilder } from "@zerotal/orm";
2
+ import { MediaItem } from "./MediaItem.ts";
3
+ import { MediaAdder } from "./MediaAdder.ts";
4
+ import { resolveCollection, type CollectionHost } from "./collections/resolve.ts";
5
+ import { mediaState } from "./state.ts";
6
+ import { fromDisk, fromPath, fromUrl, fromValue, type MediaSource } from "./sources.ts";
7
+ import type { MediaCollections, MediaOwner } from "./types.ts";
8
+
9
+ /**
10
+ * What the mixin needs from whatever it is composed onto.
11
+ *
12
+ * Declaring the shape here — rather than casting `this` at each use — is what
13
+ * keeps this file free of escape-hatch casts. `constructor` is typed as the
14
+ * concrete class so `mediaCollections` and `name` are reachable directly;
15
+ * `softDeletes` is whatever the `SoftDeletes` mixin set, if it is in the chain.
16
+ */
17
+ interface MediaHost extends MediaOwner {
18
+ id: number | string;
19
+ readonly constructor: CollectionHost & { softDeletes?: boolean };
20
+ }
21
+
22
+ // Helpers live at module scope rather than as private methods: a mixin returns an
23
+ // anonymous class type, and TypeScript refuses to emit declarations for one that
24
+ // carries private members (TS4094). Every ORM mixin has the same shape.
25
+
26
+ /** Every media row this model owns, before any collection filter. */
27
+ function ownedQuery(self: MediaHost): ModelQueryBuilder<MediaItem> {
28
+ return MediaItem.query()
29
+ .where("model_type", self.constructor.name)
30
+ .where("model_id", String(self.id));
31
+ }
32
+
33
+ /**
34
+ * Adds media handling to a model.
35
+ *
36
+ * Compose it with `Model.using(...)` and declare the collections the model
37
+ * owns in a static `mediaCollections` field. Zerotal's equivalent of Laravel's
38
+ * `HasMedia` interface plus `InteractsWithMedia` trait, in one piece — named for
39
+ * how it reads at the call site: `Model.using(Media)`.
40
+ *
41
+ * ## Deleting
42
+ *
43
+ * Hard-deleting a model deletes every file attached to it — without that, an
44
+ * upload outlives the only row that knew where it was, and you keep paying for
45
+ * storage nothing can reach. **Soft deletes are left alone**: restoring a model
46
+ * whose images had already been destroyed would be worse than an orphaned file,
47
+ * so a `SoftDeletes` model keeps its media until `forceDelete()`.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * export class Product extends Model.using(Media) {
52
+ * \@column() name!: string;
53
+ *
54
+ * static override mediaCollections: MediaCollections = {
55
+ * images: {
56
+ * accepts: ["image/jpeg", "image/png"],
57
+ * conversions: { thumb: { width: 200, height: 200 } },
58
+ * },
59
+ * };
60
+ * }
61
+ *
62
+ * const product = await Product.create({ name: "Kettle" });
63
+ * await product.addMedia(await ctx.file("photo")).toCollection("images");
64
+ * await product.getFirstMediaUrl("images", "thumb");
65
+ * ```
66
+ */
67
+ export function Media<TBase extends Constructor>(Base: TBase) {
68
+ class MediaModel extends Base {
69
+ /**
70
+ * The collections this model owns. Override in the subclass.
71
+ *
72
+ * A collection must be declared before anything can be added to it: an
73
+ * undeclared name is nearly always a typo, and silently accepting one would
74
+ * hide the mistake until someone noticed an empty gallery.
75
+ */
76
+ static mediaCollections: MediaCollections = {};
77
+
78
+ /** Primary key, supplied by the model this is composed onto. */
79
+ declare id: number | string;
80
+
81
+ /**
82
+ * The concrete subclass, typed so its statics are reachable.
83
+ *
84
+ * `Object.prototype.constructor` is `Function`, which knows nothing of
85
+ * `mediaCollections`. Re-declaring it here is what lets every method below
86
+ * read the model's collections without a cast at each use.
87
+ */
88
+ declare readonly ["constructor"]: CollectionHost & { softDeletes?: boolean };
89
+
90
+ // ── Adding ───────────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Attach a file. Nothing is read or written until `.toCollection(name)` is
94
+ * awaited.
95
+ *
96
+ * @param source - An `UploadedFile`, `File`, `Blob`, `Uint8Array` or `ArrayBuffer`.
97
+ * @param fileName - Overrides the name the source reports.
98
+ */
99
+ addMedia(source: MediaSource, fileName?: string): MediaAdder {
100
+ return new MediaAdder(this, this.constructor, fromValue(source, fileName));
101
+ }
102
+
103
+ /** Attach a file fetched over http(s). */
104
+ addMediaFromUrl(url: string): MediaAdder {
105
+ return new MediaAdder(
106
+ this,
107
+ this.constructor,
108
+ fromUrl(url, mediaState().config.maxConversionInputSize),
109
+ );
110
+ }
111
+
112
+ /** Attach a file already stored on one of the app's disks. */
113
+ addMediaFromDisk(path: string, disk?: string): MediaAdder {
114
+ return new MediaAdder(this, this.constructor, fromDisk(path, disk));
115
+ }
116
+
117
+ /** Attach a file from the local filesystem. */
118
+ addMediaFromPath(path: string): MediaAdder {
119
+ return new MediaAdder(this, this.constructor, fromPath(path));
120
+ }
121
+
122
+ /**
123
+ * Copy an existing media item onto this model.
124
+ *
125
+ * The bytes are re-read and re-stored under a fresh uuid, so the two items
126
+ * are fully independent — deleting either leaves the other's file intact.
127
+ * Name and custom properties carry over; chain the builder's methods to
128
+ * change them.
129
+ *
130
+ * @example
131
+ * await draft.copyMedia(original).toCollection("images");
132
+ */
133
+ copyMedia(media: MediaItem): MediaAdder {
134
+ return new MediaAdder(this, this.constructor, async () => ({
135
+ bytes: await media.bytes(),
136
+ originalName: media.fileName,
137
+ }))
138
+ .usingName(media.name)
139
+ .withCustomProperties(media.customProperties ?? {});
140
+ }
141
+
142
+ // ── Reading ──────────────────────────────────────────────────────────────
143
+
144
+ /** Every item in a collection, in order. */
145
+ async getMedia(collection = "default"): Promise<MediaItem[]> {
146
+ return ownedQuery(this)
147
+ .where("collection_name", collection)
148
+ .orderBy("order_column", "asc")
149
+ .orderBy("id", "asc")
150
+ .get();
151
+ }
152
+
153
+ /** The first item in a collection, or `null`. */
154
+ async getFirstMedia(collection = "default"): Promise<MediaItem | null> {
155
+ const all = await this.getMedia(collection);
156
+ return all[0] ?? null;
157
+ }
158
+
159
+ /**
160
+ * URL of the first item, or the collection's `fallbackUrl` when it is empty.
161
+ *
162
+ * Returns `""` when there is neither, so it can go straight into `src`
163
+ * without a null check.
164
+ *
165
+ * A conversion that has not been generated yet — a queued one, most likely —
166
+ * falls back to the original rather than to nothing, so a gallery is never
167
+ * blank while the queue catches up.
168
+ */
169
+ async getFirstMediaUrl(collection = "default", conversion?: string): Promise<string> {
170
+ const media = await this.getFirstMedia(collection);
171
+ if (media === null) return resolveCollection(this.constructor, collection).fallbackUrl ?? "";
172
+
173
+ const url = media.getUrl(conversion);
174
+ if (url === "" && conversion !== undefined) return media.getUrl();
175
+ return url;
176
+ }
177
+
178
+ /** Path of the first item, or the collection's `fallbackPath`. */
179
+ async getFirstMediaPath(collection = "default", conversion?: string): Promise<string> {
180
+ const media = await this.getFirstMedia(collection);
181
+ if (media === null) return resolveCollection(this.constructor, collection).fallbackPath ?? "";
182
+ return media.getPath(conversion);
183
+ }
184
+
185
+ /** Whether a collection holds anything. */
186
+ async hasMedia(collection = "default"): Promise<boolean> {
187
+ const rows = await ownedQuery(this).where("collection_name", collection).limit(1).get();
188
+ return rows.length > 0;
189
+ }
190
+
191
+ /** How many items a collection holds. */
192
+ async mediaCount(collection = "default"): Promise<number> {
193
+ const rows = await ownedQuery(this).where("collection_name", collection).get();
194
+ return rows.length;
195
+ }
196
+
197
+ // ── Removing ─────────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Delete every item in a collection, files included.
201
+ *
202
+ * @returns How many items were removed.
203
+ */
204
+ async clearMediaCollection(collection = "default"): Promise<number> {
205
+ const all = await this.getMedia(collection);
206
+ for (const media of all) await media.delete();
207
+ return all.length;
208
+ }
209
+
210
+ /** Delete every item this model owns, across every collection. */
211
+ async clearAllMedia(): Promise<number> {
212
+ const all = await ownedQuery(this).get();
213
+ for (const media of all) await media.delete();
214
+ return all.length;
215
+ }
216
+
217
+ // ── Ordering ─────────────────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Reorder a collection to match the given media ids.
221
+ *
222
+ * Ids missing from the list keep their relative order *after* the ones
223
+ * present, so handing in only the three items a drag-and-drop UI moved does
224
+ * what it looks like rather than silently discarding the rest.
225
+ */
226
+ async setMediaOrder(ids: Array<number | string>, collection = "default"): Promise<void> {
227
+ const all = await this.getMedia(collection);
228
+ const wanted = ids.map(String);
229
+
230
+ const ranked = [...all].sort((a, b) => {
231
+ const ai = wanted.indexOf(String(a.id));
232
+ const bi = wanted.indexOf(String(b.id));
233
+ if (ai !== -1 && bi !== -1) return ai - bi;
234
+ if (ai !== -1) return -1;
235
+ if (bi !== -1) return 1;
236
+ return 0;
237
+ });
238
+
239
+ for (const [index, media] of ranked.entries()) {
240
+ if (media.orderColumn === index) continue;
241
+ media.orderColumn = index;
242
+ await media.save();
243
+ }
244
+ }
245
+
246
+ // ── Lifecycle ────────────────────────────────────────────────────────────
247
+
248
+ /**
249
+ * Delete the model, cascading to its files on a hard delete.
250
+ *
251
+ * A soft-deleting model keeps its media: `restore()` is supposed to give
252
+ * back the model you had, and it cannot do that if the images were destroyed
253
+ * on the way out. Those files go on `forceDelete()` instead.
254
+ */
255
+ async delete(): Promise<void> {
256
+ if (this.constructor.softDeletes !== true) await this.clearAllMedia();
257
+
258
+ // Reached through the base prototype rather than `super` because the mixin
259
+ // is generic over a bare constructor — the same reason every ORM mixin is.
260
+ const base = Base.prototype as { delete?: () => Promise<void> };
261
+ await base.delete?.call(this);
262
+ }
263
+
264
+ /**
265
+ * Permanently delete a soft-deleting model, taking its files with it.
266
+ *
267
+ * A no-op passthrough when `SoftDeletes` is not composed in — there is no
268
+ * `forceDelete` to extend, and `delete()` has already done the cascade.
269
+ */
270
+ async forceDelete(): Promise<void> {
271
+ await this.clearAllMedia();
272
+ const base = Base.prototype as { forceDelete?: () => Promise<void> };
273
+ await base.forceDelete?.call(this);
274
+ }
275
+ }
276
+
277
+ return MediaModel;
278
+ }
@@ -0,0 +1,185 @@
1
+ import { sniffContentType } from "@zerotal/core/http";
2
+ import { MediaItem, pathGenerator } from "./MediaItem.ts";
3
+ import { resolveCollection, type CollectionHost } from "./collections/resolve.ts";
4
+ import { applyRetentionRules } from "./collections/retention.ts";
5
+ import { ConversionRunner, partitionConversions } from "./conversions/ConversionRunner.ts";
6
+ import { dispatchConversions, isQueueAvailable } from "./conversions/dispatch.ts";
7
+ import { DisallowedMimeTypeError, FileTooLargeError, UnsavedOwnerError } from "./errors.ts";
8
+ import { mediaState } from "./state.ts";
9
+ import { defaultDiskName, diskFor, diskNameFor } from "./support/disks.ts";
10
+ import type { SourceResolver } from "./sources.ts";
11
+ import type { CollectionDefinition, MediaOwner, PendingMediaMeta } from "./types.ts";
12
+
13
+ /**
14
+ * The pending file returned by `model.addMedia(...)`, awaiting a collection.
15
+ *
16
+ * Nothing is read, validated or written until {@link toCollection} runs — the
17
+ * builder just records intent, so the collection's own rules decide what is
18
+ * allowed before any bytes are buffered.
19
+ *
20
+ * @example
21
+ * await product
22
+ * .addMedia(await ctx.file("photo"))
23
+ * .usingName("Front view")
24
+ * .withCustomProperties({ alt: "Front view of the kettle" })
25
+ * .toCollection("images");
26
+ */
27
+ export class MediaAdder {
28
+ #meta: PendingMediaMeta = {};
29
+ #fileName: string | undefined;
30
+
31
+ constructor(
32
+ private readonly owner: MediaOwner,
33
+ private readonly ownerClass: CollectionHost,
34
+ private readonly resolve: SourceResolver,
35
+ ) {}
36
+
37
+ /** Set the human-facing label. Defaults to the filename without its extension. */
38
+ usingName(name: string): this {
39
+ this.#meta.name = name;
40
+ return this;
41
+ }
42
+
43
+ /**
44
+ * Override the name written to disk.
45
+ *
46
+ * The extension is still taken from the file's own bytes: a name you chose is
47
+ * yours to get right, but the stored `Content-Type` is a security boundary and
48
+ * is never client-derived.
49
+ */
50
+ usingFileName(fileName: string): this {
51
+ this.#fileName = fileName;
52
+ return this;
53
+ }
54
+
55
+ /** Attach arbitrary JSON to the media row. */
56
+ withCustomProperties(properties: Record<string, unknown>): this {
57
+ this.#meta.customProperties = { ...(this.#meta.customProperties ?? {}), ...properties };
58
+ return this;
59
+ }
60
+
61
+ /** Set an explicit sort position. Defaults to the end of the collection. */
62
+ withOrder(order: number): this {
63
+ this.#meta.order = order;
64
+ return this;
65
+ }
66
+
67
+ /** Override the collection's disk for this one file. */
68
+ toDisk(disk: string): this {
69
+ this.#meta.disk = disk;
70
+ return this;
71
+ }
72
+
73
+ /**
74
+ * Store the file, create its row, and generate whatever the collection asks for.
75
+ *
76
+ * @param collection - Name of a collection the model declares.
77
+ * @returns The saved {@link MediaItem}, with inline conversions already generated.
78
+ * @throws {UnknownCollectionError} when the model declares no such collection.
79
+ * @throws {DisallowedMimeTypeError} when the sniffed type is not accepted.
80
+ * @throws {FileTooLargeError} when the file exceeds the collection's `maxSize`.
81
+ */
82
+ async toCollection(collection = "default"): Promise<MediaItem> {
83
+ if (this.owner.id === undefined || this.owner.id === null || this.owner.id === "") {
84
+ throw new UnsavedOwnerError(this.ownerClass.name);
85
+ }
86
+
87
+ const definition = resolveCollection(this.ownerClass, collection);
88
+ const { bytes, originalName } = await this.resolve();
89
+
90
+ const sniffed = sniffContentType(bytes);
91
+ this.guard(definition, collection, sniffed.contentType, bytes.byteLength);
92
+
93
+ const config = mediaState().config;
94
+ const diskName = diskNameFor(
95
+ this.#meta.disk ?? definition.disk,
96
+ diskNameFor(config.disk, defaultDiskName()),
97
+ );
98
+ const conversionsDiskName = definition.conversionsDisk ?? config.conversionsDisk;
99
+
100
+ const media = new MediaItem();
101
+ media.modelType = this.ownerClass.name;
102
+ media.modelId = String(this.owner.id);
103
+ media.uuid = crypto.randomUUID();
104
+ media.collectionName = collection;
105
+ media.name = this.#meta.name ?? _stripExtension(originalName);
106
+ media.fileName = this.#fileName ?? `original.${sniffed.extension}`;
107
+ media.mimeType = sniffed.contentType;
108
+ media.disk = diskName;
109
+ media.conversionsDisk = conversionsDiskName === "" ? null : conversionsDiskName;
110
+ media.size = bytes.byteLength;
111
+ media.manipulations = {};
112
+ media.customProperties = this.#meta.customProperties ?? {};
113
+ media.generatedConversions = {};
114
+ media.responsiveImages = {};
115
+ media.orderColumn = this.#meta.order ?? (await _nextOrder(this.ownerClass.name, this.owner.id));
116
+
117
+ // The row is saved before the bytes land so the uuid — which the path is
118
+ // built from — is fixed and unique before anything is written under it.
119
+ await media.save();
120
+
121
+ const path = `${pathGenerator().forOriginal(media)}/${media.fileName}`;
122
+ await diskFor(diskName).put(path, bytes, { contentType: sniffed.contentType });
123
+
124
+ await this.generate(media, definition);
125
+ await applyRetentionRules(this.ownerClass.name, this.owner.id, collection, definition, media);
126
+
127
+ return media;
128
+ }
129
+
130
+ /** Enforce the collection's admission rules against the sniffed type. */
131
+ private guard(
132
+ definition: CollectionDefinition,
133
+ collection: string,
134
+ contentType: string,
135
+ size: number,
136
+ ): void {
137
+ if (definition.accepts !== undefined && !definition.accepts.includes(contentType)) {
138
+ throw new DisallowedMimeTypeError(collection, contentType, definition.accepts);
139
+ }
140
+ if (definition.maxSize !== undefined && size > definition.maxSize) {
141
+ throw new FileTooLargeError(collection, size, definition.maxSize);
142
+ }
143
+ }
144
+
145
+ /** Run inline conversions now and hand the rest to the queue. */
146
+ private async generate(media: MediaItem, definition: CollectionDefinition): Promise<void> {
147
+ const state = mediaState();
148
+ const { inline, queued } = partitionConversions(
149
+ definition.conversions,
150
+ isQueueAvailable() && state.config.queueConversions,
151
+ );
152
+
153
+ const runner = new ConversionRunner(state.driver, state.config);
154
+ await runner.run(media, inline);
155
+
156
+ if (definition.responsive !== undefined && definition.responsive !== false) {
157
+ const widths = Array.isArray(definition.responsive)
158
+ ? definition.responsive
159
+ : state.config.responsiveWidths;
160
+ await runner.runResponsive(media, widths);
161
+ }
162
+
163
+ if (Object.keys(queued).length > 0) {
164
+ await dispatchConversions(media.id as number, Object.keys(queued));
165
+ }
166
+ }
167
+ }
168
+
169
+ /** Position one past the current end of the collection. */
170
+ async function _nextOrder(modelType: string, modelId: number | string): Promise<number> {
171
+ const rows = await MediaItem.query()
172
+ .where("model_type", modelType)
173
+ .where("model_id", String(modelId))
174
+ .orderBy("order_column", "desc")
175
+ .limit(1)
176
+ .get();
177
+
178
+ const highest = rows[0]?.orderColumn;
179
+ return typeof highest === "number" ? highest + 1 : 0;
180
+ }
181
+
182
+ function _stripExtension(fileName: string): string {
183
+ const index = fileName.lastIndexOf(".");
184
+ return index > 0 ? fileName.slice(0, index) : fileName;
185
+ }