YouBothAgent▾
You — Business rules and flows you own. Read these yourself.
Both — Know the idea; your agent follows the details.
Agent — Conventions and references your agent follows. Look up as needed.
General▾
Interface▾
Observability▾
What You Build
A file feature splits one upload in two. The bytes go to storage, and the database keeps a File record that says where they are.
WhereDescription
file.constant.tsfile.document.ts
File model. Stores the File record with its name, url, size, status and progress.
file.signal.ts
Upload endpoint. Receives
Upload files and hands them to the service.file.service.ts
File service. Creates the record, streams the bytes to storage, and saves the URL.
StorageAdaptorRole
Storage adaptor. Where the bytes live; the default
BlobStorage writes to local disk.file.store.tsFile.Util.tsx
Store and UI. Upload from a file input and show the result once it is
active.localFile.getBlob
Serve endpoint. Ships in
@libs/util and streams a local file back during development.Every File starts as
uploading and becomes active when storage returns its URL. The sections below build the pieces in this order.- Already have
libs/shared? Its File module (libs/shared/lib/file) is the full version of this recipe, with image size, blur preview, dedupe by origin, and theField.Img/Field.Filecontrols.
Minimal File Model
Start with only the fields your UI needs. Image size, a blur preview or the origin URL can come later.
FieldDescription
filename
The name the user picked, handed back as the download name.
mimetype
The browser's type for the file, such as
image/png.url
Where storage serves the bytes, empty until the upload finishes.
size
The file size in bytes.
status
uploading while the bytes move, active once storage answers with a URL.progress
Upload progress from 0 to 100.
FilePurpose
Not a field but an enum: the folder a file goes to, since it becomes part of the storage path.
The constant file declares them, with the five classes every model has:
apps/myapp/lib/file/file.constant.ts
The service needs two writes on the model, one per progress tick and one when storage answers:
apps/myapp/lib/file/file.document.ts
updateByIdis one direct write. It fires no hooks, which is fine for a progress tick.finishUploadis theuploading → activestep. It saves the URL and setsprogressto 100 in the same write.
Upload Endpoint
Keep the endpoint boring. It takes the files and a purpose, and hands the real work to the service:
apps/myapp/lib/file/file.signal.ts
- An
Uploadargument makes the request multipart form data.fetch.uploadFilesaccepts aFileListorFile[]and builds the form for you. purposeis an enum, so the server rejects any other value. It becomes a folder name, and a free-form string would let the caller pick the folder.get: Usergives the UIfetch.file(id). The UI uses it to re-read a record while it uploads.- Agents never see this endpoint. An endpoint that takes
Uploadis not published over MCP.
File Service
The service is the heart of the feature. For each file it does three things:
- Create the record with status
uploading. - Use the record id in the storage path, so filenames never collide.
- When the upload finishes, save the returned URL and set the status to
active.
apps/myapp/lib/file/file.service.ts
- The upload is not awaited.
uploadFilereturns theuploadingrecord at once, and the two callbacks fill in progress and the URL later. - The path is
<purpose>/<record id>. Two users can uploadphoto.pngwithout a collision, and the filename the client sent never reaches a disk path. BlobStoragereports no progress. It callsuploadSuccessonce the write ends, so on local diskprogressjumps from 0 to 100.plug(StorageAdaptorRole)names a role, not a vendor. Swapping storage later leaves this file untouched.
Use In UI
The endpoint answers before storage finishes writing, so the record comes back as
uploading with an empty url. Keep it in the store, re-read it until it is active, then show the url.1. Upload and re-read in the store
One action uploads and keeps the record; the other refreshes it while it uploads:
apps/myapp/lib/file/file.store.ts
2. Show it in a component
An image shows as a preview, and every file gets a download link:
apps/myapp/lib/file/File.Util.tsx
- Components call
st.do, neverfetch. The store action owns the request and writes the result into state. useIntervalonly polls while the file uploads.refreshUploadedFilereturns at once for anactivefile.- Images show
url; every file can be downloaded from it.download={filename}restores the name the user picked, since the stored path holds only the id. - A link goes through
resolveServerUrl. The storedurlis relative to the server, and a page a native shell or a desktop app serves is on another origin.Imagealready resolves it.
Auto-attach To A Model Field
Writing that for every model gets repetitive. Mark one upload mutation with
{ fileUpload: true }, and every model gets helpers that upload into its File fields:You getDescription
fetch.add<Model>Files
Takes
(fileList, parentId?) and posts the files, with type set to the model's name.st.do.upload<Field>On<Model>
Takes
(fileList, index?), fills a File field of the form, and re-reads it every 3 s.Field.ImgField.ImgsField.FileField.Files
Form controls in
@libs/shared/ui that call add<Model>Files for the slice you pass.The marked mutation takes four fixed body fields in this shape. For the service method it calls, see
FileService.addFiles in libs/shared/lib/file:apps/myapp/lib/file/file.signal.ts
The four fields
files[Upload]
The files, in the order they were picked.
metasString
A JSON array with one
{ lastModifiedAt, size } per file.typeString
The owning model's name, such as
user.parentIdIDnullable
The id of the form being edited, left out when there is none.
- Mark exactly one mutation. If two carry the flag, the first one found is used and a warning is printed.
- Put it in the File module's own signal. The store action appears only on fields typed as the model whose signal holds the flag.
- Guard it like any mutation.
Everyadmits users and admins, since admin forms such as a banner editor upload too. - No flag, no helpers. Without it,
add<Model>Filesthrows "File upload is not configured".
Remove The File With Its Owner
Add
cascade: "removeRef" to a File relation, and removing the owner removes its files too. Mark the relation on the owner:apps/myapp/lib/user/user.constant.ts
The cascade calls the File service, not the File model, so
FileService._postRemove runs. Put the storage delete there and there is nothing else to wire:apps/myapp/lib/file/file.service.ts
- Arrays work too, but only on a relation. A plain string id or an embedded scalar has no document to remove.
- Nothing checks for other owners.
libs/shared's File dedupes byorigin, so two documents can share one file;removeRefdeclares that this field owns it alone. - Query-level removes skip the cascade.
removeMany,removeByIdand the generatedremove<Filter>stampremovedAtin one atomic update and fire no hooks. Remove cascading documents one at a time.


A cascade cannot be undone. The document removal is soft (
removedAt), but the storage delete is not, and restoring the owner does not bring its files back.Grow Later
Start on local disk. Once the feature works, move to S3, R2 or MinIO by swapping the storage adaptor, not by rewriting the upload API.
Local disk
BlobStorageThe default and the easiest to debug. Files land in
local/<app>/backend, and localFile.getBlob streams them back.Object storage
option.applyAdaptor(StorageAdaptorRole, S3Storage)For production and shared access. Write an
adapt() class that implements StorageAdaptor and apply it in lib/option.ts.Applying your own adaptor is one line in the app's option file:
apps/myapp/lib/option.ts
- The service stays the same. It only knows
plug(StorageAdaptorRole), so moving to S3, R2 or MinIO touches no upload code. - Using
@libs/util? ItsObjectStorageApialready speaks S3, R2, MinIO and Naver. SetobjectStorageinenv/env.server.<env>.ts;libs/shared's File service reads it throughuse<StorageApi>().
Tips
- Keep the record and the bytes apart. The database stores how to find the file, never the file itself.
- Put the File id in the storage path. Two users can then upload files with the same name.
- Progress is optional at first. It starts to matter for large files on object storage.
- Delete both when you delete. A
_postRemovethat deletes the storage object keeps the File record and the stored bytes in step.