# File Storage

`$storage` declares a named, constrained place to keep files. It works across
local disk, S3-compatible services, Cloudflare R2 and an in-memory backend for
tests.

```typescript check
import { $storage } from "alepha/api/files";
```

Every upload writes a row to the `files` table next to the blob. That row is
what makes listing, TTL expiry, tags, checksums and creator tracking possible -
so `$storage` lives in `alepha/api/files` and needs a database.

## Declaring a storage

```typescript check
import { $storage } from "alepha/api/files";

class MediaService {
  images = $storage({
    name: "user-images",
    mimeTypes: ["image/jpeg", "image/png", "image/webp"],
    maxSize: 5,
  });

  // Scratch files clean themselves up.
  temp = $storage({ ttl: [1, "day"], maxSize: 50 });
}
```

### Options

| Option          | Type                    | Default          | Description                                                             |
| --------------- | ----------------------- | ---------------- | ----------------------------------------------------------------------- |
| `name`          | `string`                | Property key     | Key prefix in the backend, and the `bucket` value on every `files` row  |
| `mimeTypes`     | `string[]`              | any              | Allowed MIME types; anything else throws `InvalidFileError`             |
| `maxSize`       | `number`                | `10`             | Maximum size in **megabytes**                                           |
| `ttl`           | `DurationLike`          | none             | Default retention; files are purged after it elapses                    |
| `clientUploads` | `boolean`               | `false`          | Let a client name this storage in `POST /api/files?bucket=` (see below) |
| `description`   | `string`                | -                | Shown in devtools and the admin UI                                      |
| `provider`      | `Service` or `"memory"` | injected default | Override the backend for this storage                                   |

`mimeTypes` is checked against the client-supplied type: a usability guard, not a security control.

> `maxSize` is in megabytes. `maxSize: 5 * 1024 * 1024` is not "5 MB" - it is
> five million megabytes, i.e. no limit at all.

## Total quota

`maxSize` caps one file. To cap what every file adds up to, across all
storages, set `maxTotalSize` on the `filesOptions` atom, also in **megabytes**.
It is **10 GB** unless you say otherwise, and `0` is unlimited.

```typescript
import { filesOptions } from "alepha/api/files";

alepha.store.mut(filesOptions, (current) => ({
  ...current,
  maxTotalSize: 2048, // 2 GB
}));
```

Or from the environment, which wins over a value set in code:

```bash
FILES_MAX_TOTAL_SIZE=2048
```

An upload that would take the total past the quota throws `FileTooLargeError`,
which `POST /api/files` answers with **413**. Every upload is checked, through
the endpoint or through `upload()`. Deleting a file gives its space back, and a
file past its `ttl` keeps counting until the purge job removes it, because its
blob still takes the space.

The check reads the current total, it does not reserve anything: two uploads
running at the same time can each fit on their own and overshoot together, and
the next upload after them is refused.

### Per-user quota

The total is shared, so one account could fill it and every other upload would
answer 413. `maxUserSize` caps what one user's uploads add up to (counted over
the rows they created), **1 GB** by default, `0` for unlimited, and
`FILES_MAX_USER_SIZE` wins over it. An upload with no user, from a job or other
server code, is held to the total only. Whichever quota is tighter refuses.

## Which storage a client may upload to

`POST /api/files` takes an optional `?bucket=`. With none, the upload lands in
the `default` storage. Naming any other storage is refused with **403** unless
it opted in:

```typescript
class Folios {
  attachments = $storage({ name: "attachments", clientUploads: true });
}
```

A storage the app writes to from server code (build artifacts, exports) stays
closed to clients, whatever its name. `$storage.upload()` and
`FileService.uploadFile()` are not affected.

## A storage is a prefix, not a bucket

Every backend keys objects as `{prefix}/{storage}/{fileId}` inside
**one** bucket (or one directory, on disk). Declaring twenty storages costs
nothing and provisions nothing - there is no per-storage cloud bucket and no
account bucket limit to worry about.

On S3 you create that one bucket yourself and point the provider at it:
`S3_ENDPOINT`, `S3_BUCKET_NAME`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and
optionally `S3_REGION` (default `auto`). On Cloudflare, an app that declares a
`$storage` gets its R2 bucket and binding provisioned by the build;
`R2_BUCKET_NAME` names it explicitly (and is required when you inject the
provider directly, since nothing then tells the build a bucket is needed).

### Sharing one bucket between apps

`S3_KEY_PREFIX` sets the leading segment, so several apps can write into the
same bucket without seeing each other's keys:

```bash
S3_KEY_PREFIX=apps/myapp/production/blobs
```

When it is unset the prefix is [`alepha.name`](/docs/guides-core-alepha-instance):
`APP_NAME`, else the build's app name, else `"app"`. Nothing is ever written at
the bucket root, so two apps with neither variable no longer overwrite each
other.

Prefer `S3_KEY_PREFIX` when a deployment platform sets the value for you.
`alepha.name` also namespaces **cookies** and the `Server-Timing` header, so
changing `APP_NAME` to lay out object storage renames every cookie and ends
every session as a side effect.

> Objects an app wrote at the bucket root while it set neither variable no
> longer resolve. Move each storage under the prefix, once per storage:
> `aws s3 mv s3://my-bucket/<storage>/ s3://my-bucket/<name>/<storage>/ --recursive`
> (any S3-compatible CLI works the same way; on R2 pass its `--endpoint-url`).

## Methods

### upload

```typescript
const stored = await this.images.upload(file, { user, tags: ["avatar"] });
stored.id; // ← the `files` row id: persist this, and use it in URLs
```

Returns the created `files` row. Validates MIME type and size first, then
writes the blob and the row. Not atomic - the blob is written before the row
because the row needs its id - so if the insert fails the blob is deleted
again, leaving nothing behind.

Per-upload options: `user`, `tags`, `ttl`, `expirationDate`.

### download

```typescript
const file = await this.images.download(stored.id);
```

Takes the row id, or an already-loaded row (which skips the lookup).

Every read and delete on a storage is scoped to it: an id belonging to another
storage is a `NotFoundError`. `download`, `get`, `delete` and `deleteMany` also
take `{ creator: userId }`, which narrows the lookup to files that user
uploaded, so an ownership check cannot be forgotten between reading the row and
checking it.

### list

```typescript
const page = await this.images.list({ tags: ["avatar"], size: 20 });
page.content; // FileEntity[]
page.page.totalElements; // total across all pages
```

A real paginated, filterable query against `files` - filter by `tags`, `name`,
`mimeType`, `creator` and a `createdAt` range. This is the thing raw blob
storage cannot do: a provider's `list()` is a flat, unfiltered, uncounted
listing capped at the backend's page size.

### get, exists, delete, deleteMany

```typescript
const row = await this.images.get(id);
const found = await this.images.exists(id);
await this.images.delete(id); // removes row + blob
await this.images.deleteMany([id1, id2]); // batched where supported
```

`deleteMany` ignores ids outside the storage and returns the ids it deleted.

## Expiry

A storage-level `ttl` stamps `expirationDate` on every row it accepts. The
`system.files.purge-deleted` cron job (registered by `AlephaApiFiles`) sweeps expired
rows and deletes their blobs. Override per upload with `ttl`, or set an exact
`expirationDate`. An `expirationDate` may shorten a file's life but never
outlive the storage's `ttl`: a later date is cut back to it, since the upload
endpoint takes that date from the client.

## Storage providers

The default depends on the environment:

- **Cloudflare Workers**: `R2FileStorageProvider` (`MemoryFileStorageProvider` under test) - the workerd bundle decides this first, so the rules below never apply there
- **Test**: `MemoryFileStorageProvider` (in-memory, lost on restart), even when `S3_ENDPOINT` is set
- **Otherwise**: `S3FileStorageProvider` when `S3_ENDPOINT` is set, else `MemoryFileStorageProvider` on a serverless runtime and `LocalFileStorageProvider` everywhere else

`LocalFileStorageProvider` writes to `node_modules/.alepha/buckets` by default,
inside the deployed bundle, so a host that unpacks each release into its own
directory loses every file on redeploy. Point it outside the bundle with
`STORAGE_PATH`, or `DATA_DIR` (blobs then land in `<DATA_DIR>/buckets`).

| Provider                    | Description                                                                     |
| --------------------------- | ------------------------------------------------------------------------------- |
| `MemoryFileStorageProvider` | In-memory, for tests                                                            |
| `LocalFileStorageProvider`  | Local filesystem                                                                |
| `R2FileStorageProvider`     | Cloudflare R2 (Workers binding)                                                 |
| `S3FileStorageProvider`     | AWS S3 / MinIO / DigitalOcean Spaces / any S3-compatible service (via `s3mini`) |

Override globally:

```typescript
import { FileStorageProvider, S3FileStorageProvider } from "alepha/bucket";

const alepha = Alepha.create().with({
  provide: FileStorageProvider,
  use: S3FileStorageProvider,
});
```

Or per storage:

```typescript
class MediaService {
  images = $storage({ provider: S3FileStorageProvider });
  tempFiles = $storage({ provider: "memory" });
}
```

## Transforming uploads

The `files:upload:before` hook fires once the storage's own MIME and size rules
have passed and before anything is written. A subscriber may replace `ev.file`
(resize, re-encode, strip metadata); `ev.storage` is the target `$storage`. It
only fires for uploads small enough to be buffered (10 MB); larger ones stream
straight to the backend. No subscriber ships with the framework.

`StoragePrimitiveOptions` is open for augmentation, so a package adding such a
concern declares its own typed `$storage` option and reads it from
`ev.storage.options`.

## Blobs without a database

`$storage` requires an ORM connection. If you genuinely need object storage
with no database, inject the provider directly:

```typescript
class Exports {
  protected readonly files = $inject(FileStorageProvider);

  async put(file: FileLike) {
    return this.files.upload("exports", file); // returns a blob id
  }
}
```

You get `upload` / `download` / `exists` / `delete` / `deleteMany` / `list`
against a container name you manage yourself - and no metadata, no expiry, no
querying, no HTTP endpoints. Reach for it only when a database is genuinely
unavailable.

## Testing

`MemoryFileStorageProvider` is the default under test:

```typescript
class TestApp {
  media = $storage({ name: "test-media" });
}

const app = alepha.inject(TestApp);
await alepha.start();

const stored = await app.media.upload(someFile);
expect(await app.media.exists(stored.id)).toBe(true);
expect((await app.media.list()).page.totalElements).toBe(1);
```
