Stored files
Documents are files the kit knows what to do with: it extracts, summarizes and indexes them. Most
products also keep files the kit should only store, such as an attachment on a note, an
organization’s logo, a user’s avatar or a CSV export. Those are stored files: one table,
stored_file, for every kind, with the documents’ upload protocol and none of their AI work.
Uploading, downloading and deleting a stored file never touches AI credits. A product that wants
AI over a file queues a job or creates a document.
The pieces:
| Piece | Where |
|---|---|
| Your file kinds | apps/api/src/files/product.ts (productFileKinds), yours |
| Upload and download | files.createUpload, files.complete, files.downloadUrl; uploadFile in the web app |
| Attaching a file to your row | claimStoredFile from @repo/db, in your procedure |
| Deleting it with your row | the own_stored_file trigger, in your migration |
| Files the server writes | writeGeneratedFile from apps/api/src/files/service.ts |
A kind is policy: where its files belong, what they may contain and who may touch them. Register
yours in apps/api/src/files/product.ts, named <area>.<name>:
export const productFileKinds = defineFileKinds({ 'note.attachment': { scope: 'workspace', contentTypes: ['application/pdf', 'image/png', 'image/jpeg'], maxBytes: 10_000_000, permissions: { upload: { note: ['create'] }, read: { note: ['read'] } }, }, 'brand.logo': { scope: 'organization', contentTypes: ['image/png', 'image/svg+xml'], maxBytes: 500_000, permissions: { upload: { organization: ['update'] } }, }, 'profile.avatar': { scope: 'user', contentTypes: ['image/png', 'image/jpeg'], maxBytes: 2_000_000 },});| Field | Meaning |
|---|---|
scope |
workspace: belongs to a workspace, deleted with it, counted toward its storage quota, readable only by members who can see the workspace. organization: belongs to the organization, deleted with it. user: belongs to an account, deleted with it, readable by that account only |
contentTypes |
The exact media types a client may declare. Anything else is BAD_REQUEST |
maxBytes |
The cap on one file. An upload is also capped by MAX_UPLOAD_BYTES |
permissions |
The role statements to upload and to read an organization’s or a workspace’s file, from packages/auth/src/product-permissions.ts or the kit’s. Without one, membership is enough for that action: every member reads the logo above |
maxUnclaimed |
How many files of the kind one organization (or account) may hold that no row has claimed yet, uploads in progress included. Default 20; past it files.createUpload answers TOO_MANY_REQUESTS. Raise it for bulk uploads |
clientUpload |
false for a kind only the server writes, such as an export: files.createUpload answers NOT_FOUND for it |
The database does not know about kinds. Renaming or removing one deletes nothing; its files simply
stop being served (NOT_FOUND), and go when their rows do.
Uploading
Section titled “Uploading”The protocol is the documents’ (Documents), on its own procedures:
files.createUpload(POST /api/v1/files) takeskind,name,contentTypeandsize, plusorganizationIdfor an organization’s kind andworkspaceIdtoo for a workspace’s. It checks the kind, the caller’s permission and the workspace’s team boundary, reserves the bytes against the workspace quota, inserts apendingfile and returns{ file, upload }, a presignedPUT.- The browser
PUTs the bytes toupload.url, a staging key nothing reads. files.complete(POST /api/v1/files/{id}/complete) measures the staging object, fixes that generation in a manifest, publishes it to a server-only key and marks the fileready. A retry publishes the same bytes to the same key; a failure answersPRECONDITION_FAILEDand can be retried. An object it refuses, over the kind’s cap (PAYLOAD_TOO_LARGE) or not the declared size (PRECONDITION_FAILED), is deleted, so the file is uploaded again before that retry.
In the web app, uploadFile from #lib/uploads.js does all three and reports progress:
const file = await uploadFile({ api, kind: 'note.attachment', organizationId, workspaceId, file: input.files[0], onProgress: (fraction) => (progress = fraction),});It rejects with an UploadError whose failure.reason is one of the documents’ reasons
(unsupported_type, too_large, storage_limit, forbidden, upload_failed, not_verified,
object_too_large, unknown) or too_many_uploads. Phrase each one in your own messages.
files.downloadUrl returns a short-lived presigned GET for a ready file, always served as an
attachment, to a member allowed to read it, or for a user-scoped file to its own account.
Attaching a file to your row
Section titled “Attaching a file to your row”An upload belongs to nobody until a row of yours claims it. Claim it in the transaction that
writes the reference, in your own procedure on orgScoped:
attach: authed.notes.attach .use(orgScoped({ note: ['create'] })) .handler(async ({ input, context, errors }) => { const { member, session, workspace } = context; await db.transaction(async (tx) => { const file = await claimStoredFile(tx, input.fileId, { kind: 'note.attachment', organizationId: member.organizationId, workspaceId: workspace?.id ?? null, uploadedBy: { userId: session.user.id }, }); if (!file) throw errors.NOT_FOUND(); await tx.insert(noteAttachments).values({ noteId: input.noteId, fileId: file.id }); }); }),claimStoredFile takes the file only if it is ready, unclaimed, of that kind, in exactly that
scope, and uploaded by uploadedBy, so a member cannot attach a file someone else uploaded.
Otherwise it returns null; answer NOT_FOUND. uploadedBy: 'server' matches only a file the
server wrote itself. A file nobody claims within a day is deleted by the daily purge.
The uploader is the signed-in user, context.session, which is why a claim runs on authed.
Stored files take no organization API keys: files.* refuse them, and on a procedure opened to
keys member.userId is null for a key, which is nobody’s uploader. See
API keys.
Deleting it with your row
Section titled “Deleting it with your row”Reference the file from your table, then attach the own_stored_file trigger, which migration
0012 installs, in a migration of yours:
export const noteAttachments = pgTable('note_attachment', { id: text().primaryKey().$defaultFn(() => crypto.randomUUID()), noteId: text().notNull().references(() => notes.id, { onDelete: 'cascade' }), fileId: text().notNull().references(() => storedFiles.id, { onDelete: 'cascade' }),});bun run db:generate --name=note_attachments writes the table’s migration. The trigger is SQL the
schema cannot hold, so it goes in a migration of its own,
bun run db:generate --custom --name=note_attachment_file:
CREATE TRIGGER "note_attachment_file" AFTER INSERT OR DELETE OR UPDATE OF "file_id" ON "note_attachment" FOR EACH ROW EXECUTE FUNCTION "own_stored_file"('file_id');From then on:
- Deleting the row deletes its file in the same transaction, whether you delete it or a cascade does (the note, the workspace, the organization).
- Pointing the column at another file deletes the old one.
- Inserting a reference to a file nobody claimed fails, and so does naming a column the table does not have, so both mistakes show up the first time the code runs.
Use on delete cascade for a link row like this one, and on delete set null for an optional
column such as logo_file_id. A table with two file columns attaches the trigger twice, once per
column. A file has one owner: it is never shared between rows or moved to another, because
clearing the column on one row deletes the file before another row could take it.
There is no files.delete. Removing an attachment is your procedure deleting the link row, under
your delete policy: the author with note: delete, anyone with deleteAny, through mayDelete.
Files the server writes
Section titled “Files the server writes”An export, a generated PDF or an import’s result never passes through a browser.
writeGeneratedFile writes the bytes straight to a server-only key, then a ready row:
const file = await writeGeneratedFile({ kind: 'report.export', organizationId, createdBy: userId, name: 'report.csv', contentType: 'text/csv', bytes, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60_000),});The ids must match the kind’s scope, a workspace must belong to the organization, and the bytes must fit the kind’s cap, or it throws. A generated file either expires or is claimed:
- With
expiresAtit is written claimed, served until then, and deleted by the daily purge after. - Without it, it is unclaimed: claim it with
uploadedBy: 'server'in the transaction that records it, or the purge deletes it after a day.
A job is the usual place to call it: queue a report.export job, write the file in its run, and
return the file’s id in the job’s result.
Who sees a file
Section titled “Who sees a file”An organization’s or a workspace’s file follows the tenant rules: files.complete and
files.downloadUrl check membership and the kind’s permission, and a file in a workspace scoped to
a team reaches only that team and the owners and admins. Another organization’s file answers
FORBIDDEN; one in another team’s workspace answers NOT_FOUND.
A user-scoped file is its account’s alone through files.downloadUrl: anyone else gets
NOT_FOUND. A product that shows one to other people, such as an avatar next to a comment,
authorizes in its own procedure and then calls presignStoredFile(row) from
apps/api/src/files/service.ts, which returns the same short-lived URL.
Quotas
Section titled “Quotas”A workspace file counts toward the workspace’s storageBytes limit together with its documents,
pending uploads included, and is refused with LIMIT_REACHED (kind: 'storageBytes') when it
would not fit, at upload and again on completion against the measured size. Organization and user
files have no plan limit; maxBytes and maxUnclaimed bound them.
Lifecycle
Section titled “Lifecycle”| Event | What deletes the file |
|---|---|
An upload never completed, or completed and never claimed, for a day; a generated file without expiresAt never claimed |
The daily purge |
A generated file past expiresAt |
The daily purge; files.downloadUrl refuses it already |
| Its owning row deleted, or pointed at another file | own_stored_file, in the same transaction |
| Its workspace, organization or account deleted | The foreign key cascade, also through your rows |
| Bytes written but the row never inserted (a crash) | The storage sweep, once unreferenced for a day |
Deleting a stored_file row queues its objects in storage_deletion, the same durable queue
documents use, so storage follows even when the bucket is down at the time.
Backups
Section titled “Backups”Stored files live in the same storage as documents, so the backup’s uploads archive carries them. The restore drill checks every ready file in the restored database against the archive: its size, and for an upload its committed marker, as it does for documents. A backup missing a file fails the drill.
Not for derivatives
Section titled “Not for derivatives”A file you can make again from one you keep, such as a thumbnail, a preview or a transcode, does not belong here: A cache of files you can regenerate.