@zerotal/arch 1.7.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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
package/docs/media.md ADDED
@@ -0,0 +1,582 @@
1
+ ---
2
+ title: Media Library
3
+ description: Attach files to models with collections, image conversions, responsive images and ordering, on any storage disk.
4
+ ---
5
+
6
+ # Media Library
7
+
8
+ Attach files to a model and let the framework handle the rest — where the bytes
9
+ live, the database row that points at them, the thumbnails, the URLs, the
10
+ ordering, and the cleanup when the model goes away.
11
+
12
+ ```ts
13
+ const media = await product.addMedia(await ctx.file("photo")).toCollection("images");
14
+
15
+ media.getUrl(); // the original
16
+ media.getUrl("thumb"); // a generated conversion
17
+ ```
18
+
19
+ ## Getting started
20
+
21
+ Install the package and register its provider:
22
+
23
+ ```bash
24
+ bun add @zerotal/media
25
+ ```
26
+
27
+ ```ts
28
+ // bootstrap/providers.ts
29
+ import { StorageProvider } from "zerotal/storage";
30
+ import { MediaProvider } from "zerotal/media";
31
+
32
+ export default [
33
+ DatabaseProvider,
34
+ StorageProvider, // media writes through disks — register it too
35
+ MediaProvider,
36
+ ];
37
+ ```
38
+
39
+ There is no migration to write. `MediaProvider` provisions the `media` table on
40
+ boot, once, only when it is missing. Set `autoCreateTable: false` in config if
41
+ you would rather own the schema yourself.
42
+
43
+ ## Declaring collections
44
+
45
+ A model gains media by composing the `Media` mixin and declaring the
46
+ collections it owns:
47
+
48
+ ```ts
49
+ // app/models/Product.ts
50
+ import { Model, column } from "zerotal/orm";
51
+ import { Media, type MediaCollections } from "zerotal/media";
52
+
53
+ export class Product extends Model.using(Media) {
54
+ @column() name!: string;
55
+
56
+ static override mediaCollections: MediaCollections = {
57
+ images: {
58
+ accepts: ["image/jpeg", "image/png", "image/webp"],
59
+ conversions: {
60
+ thumb: { width: 200, height: 200, format: "webp" },
61
+ hero: { width: 1600, format: "webp", queued: true },
62
+ },
63
+ responsive: true,
64
+ },
65
+ manual: {
66
+ single: true,
67
+ accepts: ["application/pdf"],
68
+ fallbackUrl: "/img/no-manual.svg",
69
+ },
70
+ };
71
+ }
72
+ ```
73
+
74
+ A collection has to be declared before anything can go into it. An undeclared
75
+ name throws, listing the ones that do exist — because the alternative is a typo
76
+ that silently creates a collection nobody ever reads from.
77
+
78
+ > **`Media` and `MediaItem`.** `Media` is the mixin — the thing a model _uses_,
79
+ > which is why it reads as `Model.using(Media)`. `MediaItem` is one stored file:
80
+ > a row in the `media` table, with its own URL, conversions and custom
81
+ > properties. `getMedia()` returns `MediaItem[]`.
82
+
83
+ ### Collection options
84
+
85
+ | Option | What it does |
86
+ | ------------------------------ | -------------------------------------------------------- |
87
+ | `disk` / `conversionsDisk` | Where originals and derivatives are written |
88
+ | `accepts` | Allowed MIME types, checked against the file's own bytes |
89
+ | `maxSize` | Largest accepted file, in bytes |
90
+ | `single` | A second file replaces the first |
91
+ | `onlyKeepLatest` | Keep the _n_ newest, deleting older ones |
92
+ | `fallbackUrl` / `fallbackPath` | Returned when the collection is empty |
93
+ | `conversions` | Derived images to generate |
94
+ | `responsive` | `true`, or an explicit array of widths |
95
+
96
+ ## Adding files
97
+
98
+ ```ts
99
+ // From an upload
100
+ await product.addMedia(await ctx.file("photo")).toCollection("images");
101
+
102
+ // From elsewhere
103
+ await product.addMediaFromUrl("https://example.com/a.jpg").toCollection("images");
104
+ await product.addMediaFromDisk("tmp/a.jpg", "local").toCollection("images");
105
+ await product.addMediaFromPath("/var/import/a.jpg").toCollection("images");
106
+
107
+ // Copy one item onto another model — independent bytes, fresh uuid
108
+ await draft.copyMedia(original).toCollection("images");
109
+
110
+ // With metadata
111
+ await product
112
+ .addMedia(file)
113
+ .usingName("Front view")
114
+ .withCustomProperties({ alt: "Front view", credit: "Studio B" })
115
+ .toCollection("images");
116
+ ```
117
+
118
+ Nothing is read, validated or written until `toCollection()` is awaited.
119
+
120
+ ### The type comes from the bytes
121
+
122
+ `accepts` is checked against the type sniffed from the file's own contents, not
123
+ the filename or the upload's `Content-Type` header. Both of those are supplied
124
+ by whoever is uploading, so a `payload.html` renamed to `photo.jpg` and sent as
125
+ `image/jpeg` is still rejected. The stored extension and `Content-Type` are
126
+ derived the same way.
127
+
128
+ ## Reading
129
+
130
+ ```ts
131
+ await product.getMedia("images"); // MediaItem[], in order
132
+ await product.getFirstMedia("images"); // MediaItem | null
133
+ await product.getFirstMediaUrl("images"); // or the collection's fallbackUrl
134
+ await product.getFirstMediaUrl("images", "thumb");
135
+ await product.hasMedia("images");
136
+ await product.mediaCount("images");
137
+ ```
138
+
139
+ `getFirstMediaUrl()` returns `""` when there is nothing and no fallback, so it
140
+ goes straight into `src` without a null check. Asking for a conversion that has
141
+ not been generated yet — a queued one still waiting on a worker — falls back to
142
+ the original rather than to nothing.
143
+
144
+ ## Conversions
145
+
146
+ Conversions are declared per collection and generated when a file is added:
147
+
148
+ ```ts
149
+ conversions: {
150
+ thumb: { width: 200, height: 200, format: "webp", quality: 80 },
151
+ hero: { width: 1600, queued: true },
152
+ }
153
+ ```
154
+
155
+ | Field | Meaning |
156
+ | ------------------ | -------------------------------------------------- |
157
+ | `width` / `height` | Target box |
158
+ | `fit` | `inside` (default), `fill`, or `cover` |
159
+ | `format` | `jpeg`, `png`, `webp` |
160
+ | `quality` | 1–100 |
161
+ | `rotate` | Degrees, applied before resizing |
162
+ | `allowEnlargement` | Scale small sources up to the box. Default `false` |
163
+ | `queued` | Generate on a worker instead of in the request |
164
+
165
+ Read them back with `media.getUrl("thumb")`, or `""` when that conversion does
166
+ not exist. `media.hasConversion("thumb")` when you need to branch.
167
+
168
+ ### How `fit` behaves
169
+
170
+ `cover` scales the image so it overflows the target box in at most one axis,
171
+ then keeps the centre — the square thumbnail from a 3:2 photograph. It needs
172
+ both `width` and `height`; given one, there is nothing to crop away and it
173
+ behaves as `inside`.
174
+
175
+ By default nothing is ever scaled up. If the source is too small to fill the
176
+ box, `cover` returns the largest centre window the source can supply, which may
177
+ not have the aspect ratio you asked for — a 300×500 source covering a 400×400
178
+ box gives 300×400, not 300×300. Set `allowEnlargement: true` on the conversion
179
+ when the exact box matters more than fidelity.
180
+
181
+ All of this works on the default driver, which is `Bun.Image` — built into the
182
+ runtime, no native module, nothing to install.
183
+
184
+ ### Choosing an image driver
185
+
186
+ `Bun.Image` (the default) and `sharp` support the same manipulations and are
187
+ held to the same output dimensions by a shared parity suite, so switching is a
188
+ performance decision rather than a feature one:
189
+
190
+ | | `bun` (default) | `sharp` |
191
+ | --------------------------- | ----------------------- | -------------------------------- |
192
+ | Install | Nothing | `bun add sharp`, a native module |
193
+ | `fit: "cover"` | Yes | Yes |
194
+ | Throughput on large batches | Good | Better — libvips |
195
+ | AVIF / HEIC | Host codecs (see below) | Bundled |
196
+
197
+ Switch with:
198
+
199
+ ```ts
200
+ // config/media.ts
201
+ import { MediaConfig } from "zerotal/media";
202
+ export default MediaConfig({ driver: "sharp" });
203
+ ```
204
+
205
+ ### AVIF and HEIC are host-dependent
206
+
207
+ `Bun.Image` encodes AVIF and HEIC through OS codecs that are missing on most
208
+ Linux hosts. They are off by default; set `allowHostFormats: true` to use them,
209
+ and the boot log will warn if this machine cannot. Stick to `jpeg`, `png` and
210
+ `webp` and the output is identical everywhere.
211
+
212
+ ### Queued conversions
213
+
214
+ Mark a conversion `queued: true` and it runs on a worker instead of in the
215
+ request. This needs `@zerotal/queue` registered; with no queue bound, every
216
+ conversion runs inline — late is better than a thumbnail that never appears.
217
+
218
+ ## Responsive images
219
+
220
+ Set `responsive: true` on a collection to generate a width ladder plus an inline
221
+ blur placeholder:
222
+
223
+ ```tsx
224
+ <img
225
+ src={media.getUrl()}
226
+ srcset={media.srcset()}
227
+ sizes="(max-width: 768px) 100vw, 50vw"
228
+ style={{ backgroundImage: `url(${media.placeholder})` }}
229
+ />
230
+ ```
231
+
232
+ Widths wider than the source are skipped rather than upscaled. The placeholder
233
+ is a ThumbHash-rendered data URI of a few hundred bytes — no client-side decoder
234
+ needed.
235
+
236
+ ## Custom properties
237
+
238
+ Any JSON you attach travels with the row:
239
+
240
+ ```ts
241
+ media.getCustomProperty("alt");
242
+ media.setCustomProperty("alt", "A steel kettle");
243
+ await media.save();
244
+ ```
245
+
246
+ ## Ordering
247
+
248
+ Items carry an `orderColumn`, assigned in insertion order:
249
+
250
+ ```ts
251
+ await product.setMediaOrder([third.id, first.id], "images");
252
+ ```
253
+
254
+ Ids you leave out keep their relative order after the ones you list, so handing
255
+ in only the items a drag-and-drop UI moved does what it looks like.
256
+
257
+ ## Deleting
258
+
259
+ ```ts
260
+ await media.delete(); // row + original + every derivative
261
+ await product.clearMediaCollection("images");
262
+ await product.clearAllMedia();
263
+ ```
264
+
265
+ Hard-deleting a model deletes its files too. A model using `SoftDeletes` keeps
266
+ them — `restore()` is supposed to give back the model you had, and it cannot do
267
+ that if the images went with it. Those files go on `forceDelete()`.
268
+
269
+ ## Private files
270
+
271
+ Media inherits whatever the disk does. Put a collection on a private disk and
272
+ hand out signed, expiring links instead of public URLs:
273
+
274
+ ```ts
275
+ await media.getTemporaryUrl(300); // the original, for 5 minutes
276
+ await media.getTemporaryUrl(300, "thumb"); // a conversion
277
+ ```
278
+
279
+ See [Storage](storage.md) for how disks are configured and served.
280
+
281
+ ## Where files live
282
+
283
+ ```text
284
+ media/<uuid>/original.jpg
285
+ media/<uuid>/conversions/thumb.webp
286
+ media/<uuid>/responsive/640.webp
287
+ ```
288
+
289
+ Each item gets its own directory keyed on its uuid, not its numeric id: these
290
+ paths end up in public URLs, and a sequential id there tells everyone how many
291
+ rows the table has. Supply a `PathGenerator` to change the layout.
292
+
293
+ ## Commands
294
+
295
+ ```bash
296
+ bun zt media:clean # report rows whose files are missing
297
+ bun zt media:clean --force # and delete them
298
+ bun zt media:regenerate # rebuild every conversion
299
+ bun zt media:regenerate --model=Product --only=thumb
300
+ ```
301
+
302
+ Run `media:regenerate` after changing a conversion's definition — existing files
303
+ are not reprocessed automatically.
304
+
305
+ ## Testing
306
+
307
+ Pair `Storage.fake()` with `MediaFake`. The first asserts bytes landed, the
308
+ second asserts a row points at them — a media row with no file and a file with
309
+ no row are different bugs.
310
+
311
+ ```ts
312
+ import { Storage } from "zerotal/storage";
313
+ import { MediaFake } from "zerotal/media";
314
+
315
+ const disk = Storage.fake();
316
+
317
+ await product.addMedia(file).toCollection("images");
318
+
319
+ await MediaFake.assertHas(product, "images");
320
+ await MediaFake.assertCount(product, "images", 1);
321
+ await MediaFake.assertConversion(product, "images", "thumb");
322
+ disk.assertExistsMatching(/^media\/[0-9a-f-]+\/original\.png$/);
323
+ ```
324
+
325
+ ## Configuration
326
+
327
+ ```ts
328
+ // config/media.ts
329
+ import { MediaConfig } from "zerotal/media";
330
+
331
+ export default MediaConfig({
332
+ disk: "s3",
333
+ driver: "bun",
334
+ quality: 82,
335
+ format: "webp",
336
+ responsiveWidths: [320, 640, 960, 1280, 1920],
337
+ maxConversionInputSize: 32 * 1024 * 1024,
338
+ });
339
+ ```
340
+
341
+ `maxConversionInputSize` is a real limit, not a formality: `Bun.Image` has no
342
+ streaming API, so decoding buffers the whole file. Originals above the ceiling
343
+ are still stored — they just get no conversions.
344
+
345
+ ## API reference
346
+
347
+ Signatures below are the ones `packages/media/api-surface.md` records, which CI diffs on every change. Anything importable and not listed here is `@internal`: it exists because a module inside the package needed it, and it is not covered by the stability guarantee.
348
+
349
+ ### The mixin, and one stored file
350
+
351
+ `Media` is the mixin — it reads as `Model.using(Media)`, and it declares the static `mediaCollections` field. `MediaItem` is one stored file: a row in the `media` table, and an ordinary model, so every query-builder method is available on it too.
352
+
353
+ ```ts
354
+ function Media<TBase extends Constructor>(
355
+ Base: TBase,
356
+ ): TBase & { mediaCollections: MediaCollections };
357
+ ```
358
+
359
+ `MediaItem`'s own members, on top of what a model already gives you:
360
+
361
+ | Member | What it answers |
362
+ | ------------------------------------------------- | ---------------------------------------------------------------------------- |
363
+ | `getUrl(conversion?)` | Public URL of the original, or of a named conversion |
364
+ | `getPath(conversion?)` | Path on the disk, for server-side reads |
365
+ | `getTemporaryUrl(expiresInSeconds?, conversion?)` | Signed, expiring URL — see [Private files](#private-files) |
366
+ | `getResponsivePath(width)` | Path of one rung of the responsive ladder |
367
+ | `srcset()` | A ready `srcset` string built from `responsiveImages` |
368
+ | `responsiveSet()` | The `ResponsiveImageSet` behind it, placeholder included |
369
+ | `bytes()` | The original's bytes |
370
+ | `fileExists()` | Whether the file is actually on the disk — the check `media:clean` automates |
371
+ | `deleteFiles()` | Remove originals, conversions and responsive images, leaving the row |
372
+ | `conversion(name)` / `hasConversion(name)` | One `GeneratedConversion`, or whether it exists |
373
+ | `conversionNames()` | Every conversion generated for this item |
374
+ | `getCustomProperty(key, fallback?)` | A value from `customProperties`, typed by the fallback |
375
+ | `setCustomProperty(key, value)` | Set one — chainable; call `save()` to persist |
376
+ | `forgetCustomProperty(key)` | Drop one — chainable |
377
+ | `originalDisk()` / `derivedDisk()` | The resolved `StorageDriver` for each |
378
+
379
+ Columns: `uuid`, `name`, `fileName`, `mimeType`, `size`, `disk`, `conversionsDisk`, `collectionName`, `modelType`, `modelId`, `orderColumn`, `customProperties`, `manipulations`, `generatedConversions`, `responsiveImages`, `placeholder`.
380
+
381
+ ### The adder
382
+
383
+ `addMedia(source)` returns a `MediaAdder`. Every method chains; `toCollection()` is what actually stores the file, and it returns the `MediaItem`.
384
+
385
+ ```ts
386
+ class MediaAdder {
387
+ usingName(name: string): MediaAdder;
388
+ usingFileName(fileName: string): MediaAdder;
389
+ withCustomProperties(properties: Record<string, unknown>): MediaAdder;
390
+ withOrder(order: number): MediaAdder;
391
+ toDisk(disk: string): MediaAdder;
392
+ toCollection(collection?: string): Promise<MediaItem>;
393
+ }
394
+ ```
395
+
396
+ `MediaSource` is what a source may be:
397
+
398
+ ```ts
399
+ type MediaSource = ArrayBuffer | Blob | UploadedFile | File | Uint8Array;
400
+ ```
401
+
402
+ `PendingMediaMeta` is the same metadata as an object, for callers that build it up rather than chaining: `{ name?, customProperties?, order?, disk? }`.
403
+
404
+ `MediaOwner` is the minimum a model must expose to own media — an `id`, and a constructor name, which is what lands in `model_type`.
405
+
406
+ ### Application-level operations
407
+
408
+ `MediaLibrary` is the facade; `MediaManager` is the class behind it. They are named differently because `Media` is already the mixin, and an app importing both would otherwise have to rename one at every call site.
409
+
410
+ ```ts
411
+ class MediaManager {
412
+ readonly config: MediaConfigShape;
413
+ readonly driver: ImageDriver;
414
+ clean(options?: { dryRun?: boolean }): Promise<CleanReport>;
415
+ regenerate(media: MediaItem, ownerClass: CollectionHost, only?: string[]): Promise<string[]>;
416
+ }
417
+ ```
418
+
419
+ `CleanReport` is what a sweep found, and is worth reading rather than counting:
420
+
421
+ ```ts
422
+ interface CleanReport {
423
+ /** Rows whose original file is gone from the disk. */
424
+ orphanedRows: number[];
425
+ /** Conversions recorded on a row but missing on the disk. */
426
+ danglingConversions: { mediaId: number; conversion: string }[];
427
+ /** Rows actually removed — empty on a dry run. */
428
+ deletedRows: number[];
429
+ }
430
+ ```
431
+
432
+ ### Collections and conversions
433
+
434
+ ```ts
435
+ type MediaCollections = Record<string, CollectionDefinition | (() => CollectionDefinition)>;
436
+ type ConversionMap = Record<string, ConversionDefinition>;
437
+ ```
438
+
439
+ `CollectionDefinition` is the option set documented under [Collection options](#collection-options). `ConversionDefinition` is one derived image: `width`, `height`, `fit`, `format`, `quality`, `rotate`, `allowEnlargement`, `queued`.
440
+
441
+ ```ts
442
+ type ConversionFit = "inside" | "fill" | "cover";
443
+ type SafeConversionFormat = "jpeg" | "png" | "webp";
444
+ type ConversionFormat = SafeConversionFormat | "avif" | "heic";
445
+ ```
446
+
447
+ `SafeConversionFormat` is the set that encodes on every host. `ConversionFormat` adds the two that go through OS codecs — see [AVIF and HEIC are host-dependent](#avif-and-heic-are-host-dependent).
448
+
449
+ What generation records:
450
+
451
+ ```ts
452
+ interface GeneratedConversion {
453
+ fileName: string;
454
+ size: number;
455
+ mimeType: string;
456
+ width: number;
457
+ height: number;
458
+ /** ISO-8601. */
459
+ generatedAt: string;
460
+ }
461
+
462
+ interface ResponsiveImage {
463
+ fileName: string;
464
+ width: number;
465
+ height: number;
466
+ }
467
+
468
+ interface ResponsiveImageSet {
469
+ /** Generated widths, ascending. */
470
+ images: ResponsiveImage[];
471
+ /** A `data:` low-quality placeholder, when one was produced. */
472
+ placeholder?: string;
473
+ }
474
+ ```
475
+
476
+ ### Image drivers
477
+
478
+ `ImageDriver` is the seam between this package and whatever actually manipulates pixels. Two implementations ship — `BunImageDriver` (the default, no dependencies) and `SharpImageDriver` (opt-in, a native module) — and a shared parity suite holds them to the same output dimensions.
479
+
480
+ ```ts
481
+ interface ImageDriver {
482
+ readonly name: string;
483
+ /** Whether `fit: "cover"` is available. Both shipped drivers report `true`. */
484
+ readonly supportsCrop: boolean;
485
+ metadata(bytes: Uint8Array): Promise<ImageMetadata | null>;
486
+ convert(bytes: Uint8Array, manipulation: ImageManipulation): Promise<ImageResult>;
487
+ placeholder(bytes: Uint8Array): Promise<string | null>;
488
+ canEncode(format: ConversionFormat): Promise<boolean>;
489
+ }
490
+
491
+ interface ImageManipulation {
492
+ width?: number;
493
+ height?: number;
494
+ fit?: ConversionFit;
495
+ /** Always resolved by the caller — drivers never guess. */
496
+ format: ConversionFormat;
497
+ quality?: number;
498
+ rotate?: number;
499
+ /** Never scale a source up to meet the box. Default `true`. */
500
+ withoutEnlargement?: boolean;
501
+ }
502
+
503
+ interface ImageResult {
504
+ bytes: Uint8Array;
505
+ width: number;
506
+ height: number;
507
+ format: ConversionFormat;
508
+ mimeType: string;
509
+ }
510
+
511
+ interface ImageMetadata {
512
+ width: number;
513
+ height: number;
514
+ format: string;
515
+ }
516
+ ```
517
+
518
+ Writing your own driver is supported, and the interface may grow only in ways that leave yours compiling: **new members arrive optional**, with the package supplying the fallback. `ImageManipulation` may gain optional fields; `ImageResult` and `ImageMetadata` may not gain required ones, because drivers produce them.
519
+
520
+ `BunImageDriver` takes pixel ceilings, so a decompression bomb fails as a refusal rather than as memory exhaustion. `SharpImageDriver` takes no arguments — libvips streams tiles rather than materialising the full bitmap, so the ceiling has nothing to protect.
521
+
522
+ ```ts
523
+ new BunImageDriver(maxPixels?, maxCropPixels?);
524
+ new SharpImageDriver();
525
+ ```
526
+
527
+ `SharpImageDriver` also loads `sharp` lazily, through a variable specifier, so `tsc` does not try to resolve it in the apps that never installed it — which is most of them. Selecting the driver without the package installed fails at first use with a message naming the install command.
528
+
529
+ Three lookup tables are exported for reading — to label a download, or to check a type before offering an upload. They are frozen, because they are shared module state: an app that mutated one would change how conversions behave for every other caller in the process, including ones it does not own.
530
+
531
+ ```ts
532
+ const FORMAT_MIME: Readonly<Record<ConversionFormat, string>>;
533
+ const FORMAT_EXTENSION: Readonly<Record<ConversionFormat, string>>;
534
+ const CONVERTIBLE_MIME_TYPES: ReadonlySet<string>;
535
+ function isConvertible(mimeType: string | null | undefined): boolean;
536
+ ```
537
+
538
+ `isConvertible` answers whether a stored file is worth handing to a driver at all — the check to run before offering a "regenerate thumbnails" button.
539
+
540
+ ### Paths
541
+
542
+ Supply a `PathGenerator` to change the on-disk layout described under [Where files live](#where-files-live). `DefaultPathGenerator` is the shipped one, and `setPathGenerator` installs yours. It is process-global, so a provider's `register()` is the place for it.
543
+
544
+ ```ts
545
+ interface PathGenerator {
546
+ forOriginal(media: MediaItem): string;
547
+ forConversions(media: MediaItem): string;
548
+ forResponsiveImages(media: MediaItem): string;
549
+ }
550
+
551
+ class DefaultPathGenerator implements PathGenerator {
552
+ constructor(prefix?: string);
553
+ }
554
+
555
+ function setPathGenerator(generator: PathGenerator): void;
556
+ ```
557
+
558
+ ```ts
559
+ // A provider's register()
560
+ setPathGenerator(new DefaultPathGenerator("uploads"));
561
+ ```
562
+
563
+ ### Command classes
564
+
565
+ `MediaCleanCommand` and `MediaRegenerateCommand` back `media:clean` and `media:regenerate`. `MediaProvider` registers both; they are exported from `@zerotal/media/commands` so an app can subclass one to change its defaults.
566
+
567
+ ### Errors
568
+
569
+ Every failure is a `MediaError` subclass carrying a stable `code`, an HTTP `status` and a `context` object — so a handler can branch on the code rather than matching a message.
570
+
571
+ | Class | Code | Status | Raised when |
572
+ | ------------------------------ | ---------------------------------- | ------ | --------------------------------------------------------------- |
573
+ | `UnknownCollectionError` | `E_MEDIA_UNKNOWN_COLLECTION` | 500 | A collection name matches nothing the model declares |
574
+ | `DisallowedMimeTypeError` | `E_MEDIA_DISALLOWED_MIME_TYPE` | 422 | The sniffed type is not in the collection's `accepts` |
575
+ | `FileTooLargeError` | `E_MEDIA_FILE_TOO_LARGE` | 422 | The file exceeds the collection's `maxSize` |
576
+ | `UnsavedOwnerError` | `E_MEDIA_UNSAVED_OWNER` | 500 | `addMedia` on a model with no primary key yet |
577
+ | `UnsupportedFormatError` | `E_MEDIA_UNSUPPORTED_FORMAT` | 500 | This host cannot encode the requested format |
578
+ | `UnsupportedManipulationError` | `E_MEDIA_UNSUPPORTED_MANIPULATION` | 500 | The driver cannot do what the conversion asks (a crop, usually) |
579
+ | `RasterFormatError` | `E_MEDIA_RASTER_FORMAT` | 500 | A decoded image could not be re-encoded |
580
+ | `MediaFileMissingError` | `E_MEDIA_FILE_MISSING` | 404 | A row points at a file the disk does not have |
581
+
582
+ `UnknownCollectionError` lists the collections the model _does_ declare, because the mistake is nearly always a typo.