# Media

> Give files durable IDs and project-scoped libraries. Keep storage paths, browser folders and permission checks separate.

- Package: `@db3.ai/app/media`
- Canonical page: [https://db3.ai/docs/media](https://db3.ai/docs/media)
- Markdown: [https://db3.ai/docs/media.md](https://db3.ai/docs/media.md)
- Framework source of truth: `packages/app/src/media/README.md`

<a id="setup"></a>

## Set up storage and media tables

Storage owns the bytes. Media owns `MediaLibrary`, `MediaFile` and `MediaItem` records. The application supplies a storage disk and a database containing those tables.

The lab uses temporary storage and installs the three models into a generated test database. An application must use committed migrations and a durable disk. A browser placement is a data record; the framework does not automatically install a Vue media browser.

- [Installation and test SQL configuration](https://db3.ai/docs/installation.md#database-labs)
- [Configure Storage](https://db3.ai/docs/storage.md)

<a id="copy"></a>

## Copy the project-files example

Use the independent app directory and test configuration from Installation. The example uses two trusted project identities to demonstrate scoped reads.

### Copy the shipped example

```bash
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/media/examples/. examples/
```

<a id="run"></a>

## Run it

The result contains `/Briefs/brief.txt`, a numbered duplicate `/Briefs/brief-2.txt`, and true values for private visibility, cross-project rejection and deletion cleanup. The generated database and temporary files are removed afterwards.

### Run the lab

```bash
npx tsx examples/runProjectMedia.ts
```

<a id="libraries"></a>

## Give a project its own library

`libraryFor()` finds or creates a library identified by `scopeType`, `scopeId` and `key`. Your app decides what a project is and verifies access before resolving that library.

A library can supply a default disk and path prefix. Treat scope values as trusted application inputs, not arbitrary values copied from a request body. Library identity is not an access-control check.

### examples/runProjectMedia.ts

```typescript
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { pathToFileURL } from 'node:url';
import { createGeneratedTestDatabase } from '@db3.ai/app/db/test/db';
import { MediaFile, MediaItem, MediaLibrary } from '@db3.ai/app/media';
import { App } from '@db3.ai/app/server';

/**
 * Stores a project brief, makes a browser placement and proves library isolation.
 *
 * The project identity is trusted lab input. Real endpoints must authorize the
 * project before resolving its library, and scope file reads to that library.
 *
 * @returns Stable results after removing the lab's database and temporary files.
 */
export async function runProjectMedia() {
	const root = await mkdtemp(join(tmpdir(), 'db3-media-guide-'));
	try {
		const database = await createGeneratedTestDatabase('media_guide');
		const application = new App({ db: database.db, storage: { disks: { local: { driver: 'local', root } } } });
		try {
			await application.db.install(MediaLibrary, MediaFile, MediaItem);
			const library = await application.media.libraryFor({ scopeType: 'project', scopeId: 'client-a', name: 'Client files', pathPrefix: 'projects/client-a' });
			const otherLibrary = await application.media.libraryFor({ scopeType: 'project', scopeId: 'client-b' });
			const stored = await application.media.storeVisibleFileStream({ library, name: 'brief.txt', stream: Readable.from(['Client brief']), mimeType: 'text/plain', folderPath: '/Briefs', source: 'upload' });
			const duplicate = await application.media.storeVisibleFile({ library, name: 'brief.txt', contents: 'Revised brief', mimeType: 'text/plain', folderPath: '/Briefs' });
			const file = await MediaFile.where({ id: stored.file.id, library }).firstOrFail();
			const text = (await application.media.readFile(file)).toString('utf8');
			const crossProjectRejected = await MediaFile.where({ id: file.id, library: otherLibrary }).first() === null;
			const result = { text, path: stored.item.path, duplicatePath: duplicate.item.path, private: file.visibility === 'private', crossProjectRejected };
			const originalPath = file.path!;
			await application.media.deleteFile(file);
			return { ...result, deleted: await application.storage.missing(originalPath), placementRemoved: await MediaItem.where('id', stored.item.id).first() === null };
		} finally {
			try { await application.close(); } finally { await database.destroy(); }
		}
	} finally {
		await rm(root, { recursive: true, force: true });
	}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
	console.log(JSON.stringify(await runProjectMedia(), null, 2));
}
```

<a id="store"></a>

## Store a file or place it in a folder

`storeFile()` creates a managed file without a browser item. `storeVisibleFile()` also creates a placement and any missing folders. Use `storeFileStream()` or `storeVisibleFileStream()` for an assembled upload stream.

Browser paths can change while the file ULID remains stable. Duplicate placement names are numbered rather than replacing an existing file. `ensureFolder()` and `attachFile()` work with existing library/file records.

Do not supply a path or byte count from untrusted input without validating it. Ordinary stream storage does not inspect the file contents or implement a resumable upload protocol.

<a id="read"></a>

## Read a file within its authorized library

Load the file with both its ID and authorized library, as the example does with `MediaFile.where({ id, library })`. Only then call `readFile()` or `readFileStream()`.

`media.file(id)` is an unscoped lookup, not an authorization check. A guessed ULID must not let one project read another project’s files. Your route also owns MIME policy, response headers and download disposition.

<a id="images"></a>

## Validate image uploads and render variants

For untrusted images, perform a cheap signature/type allowlist at the upload boundary, then use `storeReconstructedVisibleImageStream()`. The processor decodes and reconstructs the file, stripping metadata and trailing bytes before saving media records.

`renderImage()` generates responsive variants through the image processor and disposable cache. `imageVariantOptionsFromSearchParams()` limits the request options; supported widths are 1–4096. Decoding is bounded by a 40-megapixel input limit and 20-second processing timeout.

Configure cache disk/prefix under `config.media.images`. Authorize the original file before rendering a variant. The project-text-file lab does not exercise reconstruction or image caching; the framework has separate processor and renderer tests.

<a id="visibility"></a>

## Choose private or public delivery deliberately

Files default to private storage visibility. A browser-visible item is not a public file, and setting metadata to public does not install a public HTTP route.

Private delivery needs an authenticated, scoped route. Public delivery needs an explicit policy for what may be published. Media does not check whether a file is safe to display as HTML, whether an SVG contains active content, or whether your app is permitted to share its contents.

<a id="delete"></a>

## Delete and recover

Before `deleteFile()`, check app-owned references and whether deletion is allowed. The manager removes generated variants and source bytes, then the managed row; foreign keys remove browser placements.

Database and object storage are not one transaction. Plan for partial failures and orphan reconciliation in an application that needs stronger guarantees. Reconstructed-image failures have their own partial-object cleanup, but that is not a claim of atomicity for every file operation.

<a id="testing"></a>

## Testing

Create a `tests/media` directory and save the test below as `runProjectMedia.test.ts`. It imports the example you copied into `examples/`. Keep the same folder layout so the relative import resolves.

Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout. This database lab needs the same test-only SQL credentials when run through Vitest.

### tests/media/runProjectMedia.test.ts

```typescript
import { expect, it } from 'vitest';
import { runProjectMedia } from '../../examples/runProjectMedia';

it('runs the project media guide with scoped SQL reads, real streams and cleanup', async () => {
	expect(await runProjectMedia()).toEqual({
		text: 'Client brief', path: '/Briefs/brief.txt', duplicatePath: '/Briefs/brief-2.txt',
		private: true, crossProjectRejected: true, deleted: true, placementRemoved: true,
	});
});
```

<a id="run-tests"></a>

## Run and extend the test

Run with dedicated SQL credentials and local temporary storage. Try a different folder or a duplicate filename. Keep the cross-project read test: it proves the scoped lookup used by this example, not the application’s membership policy.

### Run your copied test and check types

```bash
npx vitest run tests/media/runProjectMedia.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts
```

<a id="coverage"></a>

## Coverage and reference

Tested here: scoped libraries, streamed storage, browser placement, duplicate names, scoped reads, private defaults and deletion cleanup. The private-file guide adds real HTTP authentication, input limits, headers and foreign-user rejection.

Still to build: browser integration, source-backed image-processing walkthroughs and orphan reconciliation. Image APIs also have the `@db3.ai/app/media/image` public subpath.

- [Full Media API](https://db3.ai/docs/media-api.md)
- [Private upload and download](https://db3.ai/docs/guide-files.md)

## Behavioural verification
Streams a project file, places it in a folder, resolves duplicate names, scopes reads and verifies file/placement cleanup.
- Behaviour test: `packages/app/src/media/tests/examples/runProjectMedia.test.ts`
- Repository test command (framework checkout only): `npm run test:service --workspace @platform/app -- media --maxWorkers=1`
- In an installed application, use the walkthrough commands instead of this repository test.
- Expected outcome: The guide test passes against the real framework components.
- Environment: A dedicated MariaDB/MySQL test account and temporary local disk. No real uploads or provider requests.

## Related documentation
- [Install the framework](https://db3.ai/docs/installation.md): Install one runtime package, then build a small HTTP app. Add a database and other services when your feature needs them.
- [Storage](https://db3.ai/docs/storage.md): Write files to a named disk, stream large payloads and keep paths relative to storage. Add Media when files need durable identities and ownership metadata.
- [Auth](https://db3.ai/docs/auth.md): Give an account one or more login methods. Issue bearer sessions, reset passwords and revoke access without mixing identity with credentials.
- [Queue](https://db3.ai/docs/queue-overview.md): Create durable background jobs, run named workers, understand every attempt, compose chains and batches, and recover failures through one complete service guide.

## Framework-owned source: `packages/app/src/media/README.md`

This is the exact source document captured by the documentation build. Use it for detailed API and workflow guidance, subject to the public package exports and behavioural evidence identified above.

````markdown
# @db3.ai/app/media

`@db3.ai/app/media` is the framework-level file and media-browser layer that
sits above `@db3.ai/app/storage`.

Storage owns disks, paths, streams, and bytes. Media owns durable file ULIDs,
library scopes, metadata, and optional browser tree rows.

## Tables

The media package uses three tables:

```text
media_libraries
media_files
media_items
```

`media_libraries` is the ownership and configuration boundary. It stores an
app-owned `scope_type`, `scope_id`, and `library_key`, plus optional storage
defaults such as `default_disk` and `path_prefix`.

`media_files` stores managed file records. A file row can exist without being
visible in a browser.

`media_items` stores browser-visible rows: root folders, directories, and file
placements. A `file` item points to a `media_files` row.

## Scoped Libraries

The framework does not know about application concepts such as websites,
organizations, teams, or users. Apps map those concepts into a scoped library:

```ts
const library = await app.media.libraryFor({
	scopeType: 'scout.website',
	scopeId: website.id,
	key: 'default',
	name: 'Website media',
	pathPrefix: `websites/${website.id}/media`,
});
```

The unique library identity is:

```text
scope_type + scope_id + library_key
```

This lets one app start with one default library per scope, while still allowing
future libraries such as `generated-images`, `reference-material`, or
`brand-assets` when they need separate configuration or workflows.

## Managed Files Without Browser Items

Use `storeFile()` when a file should be managed, loaded by ULID, and scoped to a
library, but should not appear in the media browser.

```ts
const file = await app.media.storeFile({
	library,
	contents: Buffer.from('private export'),
	name: 'export.txt',
	mimeType: 'text/plain',
	source: 'export',
	meta: {
		jobId: job.id,
	},
});

const bytes = await app.media.readFile(file.id);
```

This creates a `media_files` row only.

## Browser-Visible Files

Use `storeVisibleFile()` when a file should also appear in the browser tree.

```ts
const stored = await app.media.storeVisibleFile({
	library,
	contents: imageBytes,
	name: 'hero-preview.png',
	mimeType: 'image/png',
	source: 'generated-image',
	folderPath: '/Generated images',
	meta: {
		model: 'gpt-image-2',
		prompt,
	},
});

stored.file.id;
stored.item.path;
```

This creates:

- one `media_files` row for the bytes and metadata
- one `media_items` file row for browser placement
- any missing folder rows in the requested folder path

Browser paths are unique within a library. When the requested filename already
exists in the destination folder, media placement keeps the managed file and
its ULID unchanged while numbering the browser item: `moon.png`, `moon-2.png`,
`moon-3.png`, and so on.

## Streaming Completed Uploads

Trusted HTTP adapters can stream an assembled file into managed media without
buffering the complete payload in application memory:

```ts
import { createReadStream } from 'node:fs';

const stored = await app.media.storeVisibleFileStream({
	library,
	stream: createReadStream(temporaryPath),
	size: upload.size,
	name: upload.name,
	mimeType: upload.mimeType,
	source: 'upload',
	folderPath: '/Campaigns',
});
```

Use `storeFileStream()` for a managed file that should not appear in the
browser. Supply a trusted byte size when it is known. When size is omitted,
storage measures the completed object before the `media_files` row is saved.

Untrusted image uploads should instead use canonical reconstruction:

```ts
const stored = await app.media.storeReconstructedVisibleImageStream({
	library,
	stream: createReadStream(temporaryPath),
	name: upload.name,
	mimeType: detectedMimeType,
	source: 'upload',
	folderPath: '/Campaigns',
});
```

The upload adapter should first apply a cheap signature allowlist and require
the detected type to match its declared type. The framework then passes the
complete stream through `ImageProcessor.reconstruct()`. Sharp fully decodes the
input within its pixel and timeout limits, applies orientation, strips metadata
and trailing bytes, and emits a new image in the same canonical format.

The reconstructed stream writes directly to a generated durable path without
buffering the complete source or output in JavaScript memory. The framework
does not save a media row or browser item until the stream completes. Processor
failures remove the partial durable object and raise `ImageReconstructionError`,
allowing an upload adapter to return a validation response rather than a
storage error.

The media package deliberately does not implement an HTTP upload protocol.
Authentication, resumable offsets, temporary-resource expiry, and protocol
responses belong to an app or server adapter above this storage-neutral seam.

## Loading Files

The file ULID is the durable reference. Folder paths can change without
changing the file id.

```ts
const file = await app.media.file(fileId);
const bytes = file ? await app.media.readFile(file) : null;
const stream = file ? await app.media.readFileStream(file) : null;
```

Routes remain responsible for authentication, authorization, headers, and
response shaping. The media package only knows the library scope, not whether a
given user can access that scope.

## Deleting Files

Use `deleteFile()` after the application has applied its own ownership and usage
policy:

```ts
await app.media.deleteFile(file);
```

The manager removes disposable responsive-image variants, deletes the durable
source bytes, and then deletes the `media_files` row. Database foreign keys
remove every browser placement that points at the file. Application references
such as article content remain app-owned and must be checked before calling this
framework method.

## Visibility And Public Delivery

`media_files.visibility` records the storage visibility used when the file was
written. It does not make a file public by itself. Apps should expose public
media through deliberate routes that load the file by ULID, check the source and
visibility they are willing to publish, and then write the response headers.

Generated article images can therefore be stored as browser-visible public
files. Media browsers can render those deliberate public routes directly so
normal browser image caching applies, while private files continue to use an
authenticated app route and browser object URL.

## Responsive Image Variants

The `media/image` module translates an allowlisted URL query into a framework
image command, renders cache misses through an `ImageProcessor`, and stores the
result on a configured disposable storage disk.

```ts
import { imageVariantOptionsFromSearchParams } from '@db3.ai/app/media';

const widthOptions = imageVariantOptionsFromSearchParams(requestUrl.searchParams);
const options = requestUrl.pathname.endsWith('.webp')
	? widthOptions ?? {}
	: widthOptions;

if (options) {
	const image = await app.media.renderImage(file, options);

	reply
		.header('content-type', image.mimeType)
		.send(image.stream);
}
```

The request contract supports width while preserving aspect ratio:

```text
/api/media/images/<media-file-ulid>.webp?w=640
```

To compress an image at its original dimensions, request WebP without a width:

```text
/api/media/images/<media-file-ulid>.webp
```

An article can use that full-size compressed image as its fallback while
letting the browser select a smaller cached width:

```html
<img
	src="/api/media/images/<media-file-ulid>.webp"
	srcset="
		/api/media/images/<media-file-ulid>.webp?w=480 480w,
		/api/media/images/<media-file-ulid>.webp?w=960 960w,
		/api/media/images/<media-file-ulid>.webp?w=1440 1440w
	"
	sizes="(max-width: 48rem) 100vw, 48rem"
	width="1536"
	height="1024"
	loading="lazy"
	decoding="async"
	alt="Useful description of the image"
/>
```

JPEG, PNG, WebP, and AVIF sources produce quality-controlled WebP variants,
apply EXIF orientation, and never enlarge the original. The source URL without
any transform query remains untouched. SVG and GIF files pass through unchanged
because SVG is already responsive and GIF animation must be preserved.

Generated variants are streamed from durable source storage through the
processor and into disposable cache storage without buffering the complete
source or result in JavaScript memory. Cache paths are versioned and keyed by
the immutable managed-file ULID:

```text
image-cache/v1/<file-ulid>/w-640.webp
image-cache/v1/<file-ulid>/original.webp
```

Configure the cache disk and prefix under `config.media`. Omitting `cacheDisk`
keeps the cache on the source disk, while applications with a disposable disk
can isolate every generated variant:

```ts
const media = {
	images: {
		cacheDisk: 'tmp',
		cachePrefix: 'image-cache',
	},
};
```

Deleting the cache directory never removes originals. A later request
regenerates the missing variant using the managed source file.

The default `SharpImageProcessor` uses Sharp/libvips. It is isolated behind the
`ImageProcessor` contract so another engine, such as an ImageMagick adapter, can
replace it without changing request parsing, cache identity, media ownership, or
storage.

Safety bounds currently include:

- widths from 1 through 4096 pixels
- a 40-megapixel decoded input limit
- a 20-second processing timeout
- at most two active cache-generating renders per application process
- coalescing concurrent requests for the same variant into one render

Canonical upload reconstruction uses the same 40-megapixel and 20-second
processor bounds. Animated GIF and WebP inputs decode and reconstruct every
frame within the total pixel limit.

Unknown query parameters never become processor commands. Apps remain
responsible for resolving the media ULID and applying authorization before
calling the framework renderer.

## Installation

For an application, add the three models through committed migrations. The
`install` call below is appropriate only for a disposable lab or explicit local
schema setup, not request handling or production server startup.

Install the models alongside your app models:

```ts
import { MediaFile, MediaItem, MediaLibrary } from '@db3.ai/app/media';

await app.db.install(
	MediaLibrary,
	MediaFile,
	MediaItem,
);
```

When using `App`, the media manager is available as:

```ts
await app.media.storeFile(...);
```

## Runnable example and testing

For an authenticated route, copy the Media examples and install `fastify@5`.
Run `npx tsx examples/runPrivateFiles.ts` with the same disposable SQL settings.
It exercises a 64 KiB bounded text upload, owner-scoped streamed download,
private response headers, cross-user rejection, invalid input without writes,
deletion and retry. The exact consumer test is
`tests/examples/runPrivateFiles.test.ts`. Bearer tokens stay inside the lab;
deployment needs HTTPS and the application's session/issuance policy. This is
not a resumable upload, arbitrary-file validator or public publishing endpoint.

The [project media lab](./examples/runProjectMedia.ts) streams a private project
file, creates browser placements, handles duplicate names, scopes a read to the
authorized library and checks byte/placement cleanup on deletion. Copy it into
`examples` and run `npx tsx examples/runProjectMedia.ts` with a dedicated SQL
test account allowed to create/drop `db3_app_test_*` databases.

The lab removes its database and temporary local disk in `finally`. The website
includes the exact consumer-copyable test. Image reconstruction and variant
caching have separate service tests; this text-file workflow does not exercise
them. Framework contributors run
`npm run test:service --workspace packages/app -- media`.
````

## Guidance for AI tools
Use the documented public import `@db3.ai/app/media` and its exported types. Prefer the source-backed examples and behavioural outcomes above over invented APIs or source-relative internal imports.
