Skip to content

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.

The protocol is the documents’ (Documents), on its own procedures:

  1. files.createUpload (POST /api/v1/files) takes kind, name, contentType and size, plus organizationId for an organization’s kind and workspaceId too 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 a pending file and returns { file, upload }, a presigned PUT.
  2. The browser PUTs the bytes to upload.url, a staging key nothing reads.
  3. 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 file ready. A retry publishes the same bytes to the same key; a failure answers PRECONDITION_FAILED and 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.

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:

apps/api/src/rpc/notes.ts
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.

Reference the file from your table, then attach the own_stored_file trigger, which migration 0012 installs, in a migration of yours:

packages/db/src/schema/notes.ts
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.

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 expiresAt it 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.

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.

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.

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.

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.

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.