alepha@docs:~/docs/guides/persistence$
cat 4-storage.md | pretty
7 min read
Last commit:

#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
1import { $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
 1import { $storage } from "alepha/api/files"; 2  3class MediaService { 4  images = $storage({ 5    name: "user-images", 6    mimeTypes: ["image/jpeg", "image/png", "image/webp"], 7    maxSize: 5, 8  }); 9 10  // Scratch files clean themselves up.11  temp = $storage({ ttl: [1, "day"], maxSize: 50 });12}

#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
1import { filesOptions } from "alepha/api/files";2 3alepha.store.mut(filesOptions, (current) => ({4  ...current,5  maxTotalSize: 2048, // 2 GB6}));

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
1class Folios {2  attachments = $storage({ name: "attachments", clientUploads: true });3}

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: 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
1const stored = await this.images.upload(file, { user, tags: ["avatar"] });2stored.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
1const 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
1const page = await this.images.list({ tags: ["avatar"], size: 20 });2page.content; // FileEntity[]3page.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
1const row = await this.images.get(id);2const found = await this.images.exists(id);3await this.images.delete(id); // removes row + blob4await 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
1import { FileStorageProvider, S3FileStorageProvider } from "alepha/bucket";2 3const alepha = Alepha.create().with({4  provide: FileStorageProvider,5  use: S3FileStorageProvider,6});

Or per storage:

typescript
1class MediaService {2  images = $storage({ provider: S3FileStorageProvider });3  tempFiles = $storage({ provider: "memory" });4}

#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
1class Exports {2  protected readonly files = $inject(FileStorageProvider);3 4  async put(file: FileLike) {5    return this.files.upload("exports", file); // returns a blob id6  }7}

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
 1class TestApp { 2  media = $storage({ name: "test-media" }); 3} 4  5const app = alepha.inject(TestApp); 6await alepha.start(); 7  8const stored = await app.media.upload(someFile); 9expect(await app.media.exists(stored.id)).toBe(true);10expect((await app.media.list()).page.totalElements).toBe(1);