Storage Tables
The storage_files table, upload records, usage tracking, and expiration cleanup.
Storage table schemas live in:
src/core/db/schema/sqlite/storage.schema.ts
src/core/db/schema/pg/storage.schema.tsObject storage itself is handled by src/modules/storage. Browser uploads use presigned multipart URLs, then write storage_files records for usage tracking, file lookup, and cleanup.
Business boundary
storage_files stores metadata only. File bytes live in S3-compatible storage such as Cloudflare R2 or AWS S3.
Related files:
| File | Responsibility |
|---|---|
src/modules/storage/provider.ts | S3-compatible provider and URL generation |
src/modules/storage/services/browser-upload.service.ts | Client upload flow |
src/modules/storage/services/multipart-upload.service.ts | Presigned parts and multipart completion |
src/modules/storage/repositories/storage-file.repository.ts | File records, deletion, expiration queries |
app/api/storage/presigned-url/* | Browser upload APIs |
app/api/storage/usage/route.ts | User storage usage API |
storage_files
| Field | Description |
|---|---|
id | File record ID |
user_id | Owner user |
object_key | Unique object storage key |
file_name | Display filename |
file_size | Size in bytes |
mime_type | Optional MIME type |
expires_at | Expiration timestamp; null means never expires |
created_at | Record creation time |
object_key is not a full URL. It is a storage key such as:
userId/avatar/uuid-file.pngResolve it to a public URL when displaying files.
Upload write flow
Create presigned upload
The client calls POST /api/storage/presigned-url. The server checks auth, file size, expiration, and quota, then returns objectKey, uploadId, and part URLs.
Upload directly to object storage
The browser sends file parts directly to object storage with PUT.
Complete and insert record
The client calls POST /api/storage/presigned-url/complete. The server completes the multipart upload, reads the actual size with HeadObject, then inserts storage_files.
Track usage
getUserStorageUsage(userId) sums storage_files.file_size. GET /api/storage/usage returns used bytes and quota.
Expiration and cleanup
Upload expires is in hours:
-1: never expires,expires_at = null- positive integer: current time plus that many hours
The repository provides:
getExpiredFiles(batchSize);
deleteFileRecordByKey(objectKey);ShipNext does not include a cleanup cron by default. Add one if you need automatic object deletion.
User avatars
Profile avatar upload stores the returned key in user.image. Display code uses:
import { resolveUserImageUrl } from "@/modules/storage";
const src = user.image ? resolveUserImageUrl(user.image) : null;So user.image can be either a full URL or a storage object key.
Billing quota relationship
| Config | Location | Purpose |
|---|---|---|
plan.storageLimit | src/modules/billing/config/plan.ts | Dashboard usage display |
EntitlementResourceType.Storage | prices[].entitlements | Upload API quota enforcement |
If upload API returns quota: null, no API-level storage quota is enforced.
Extension guidance
- Continue storing
object_key, not only full URLs. - Delete both object storage files and
storage_filesrecords when removing files. - Keep field meanings stable if you add a non-S3 provider.
- Trust server-side
HeadObjectsize over client-provided file size.