#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 |
description |
string |
- | Shown in devtools and the admin UI |
provider |
Service or "memory" |
injected default | Override the backend for this storage |
maxSizeis in megabytes.maxSize: 5 * 1024 * 1024is not "5 MB" — it is five million megabytes, i.e. no limit at all.
#A storage is a prefix, not a bucket
Every backend keys objects as {APP_NAME}/{tenantId}/{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.
You create that one bucket yourself and point the provider at it
(S3_BUCKET_NAME / R2_BUCKET_NAME).
#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).
#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
#Expiry
A storage-level ttl stamps expirationDate on every row it accepts. The
api:files:purgeFiles cron job (registered by AlephaApiFiles) sweeps expired
rows and deletes their blobs. Override per upload with ttl, or set an exact
expirationDate.
#Storage providers
The default depends on the environment:
- Test / serverless:
MemoryFileStorageProvider(in-memory, lost on restart) - Cloudflare Workers:
R2FileStorageProvider - Otherwise:
S3FileStorageProviderwhenS3_ENDPOINTis set, elseLocalFileStorageProvider
| 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()4 .with({ provide: FileStorageProvider, use: S3FileStorageProvider });
Or per storage:
1class MediaService {2 images = $storage({ provider: S3FileStorageProvider });3 tempFiles = $storage({ provider: "memory" });4}
#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);