#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.
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
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.
maxSizeis in megabytes.maxSize: 5 * 1024 * 1024is 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.
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:
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:
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:
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
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
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
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
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(MemoryFileStorageProviderunder test) - the workerd bundle decides this first, so the rules below never apply there - Test:
MemoryFileStorageProvider(in-memory, lost on restart), even whenS3_ENDPOINTis set - Otherwise:
S3FileStorageProviderwhenS3_ENDPOINTis set, elseMemoryFileStorageProvideron a serverless runtime andLocalFileStorageProvidereverywhere 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:
1import { FileStorageProvider, S3FileStorageProvider } from "alepha/bucket";2 3const alepha = Alepha.create().with({4 provide: FileStorageProvider,5 use: S3FileStorageProvider,6});
Or per storage:
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:
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:
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);