Skip to content
TALA
Esc
navigateopen⌘Jpreview
On this page

Uploads

The three-step S3 multipart pipeline — initiate, parallel PUT, complete — and the abort path that keeps S3 clean.

Files never pass through the API. The backend authorises the target, mints presigned URLs, and the browser uploads directly to S3.

The three steps

Initiate

POST /api/upload/initiate
Content-Type: application/json

{
  "fileName": "brand-mark.png",
  "contentType": "image/png",
  "partCount": 5,
  "ownerScope": "organization",
  "collectionId": "CLa7Bk9x2Q",
  "projectId": "PRa7Bk9x2Q",
  "collectionIds": ["CLa7Bk9x2Q", "CLb8Cm0y3R"]
}

The backend authorises the target before minting anything, then returns an uploadId, a key, and one presigned URL per part.

collectionId and collectionIds are not alternatives — which one applies depends on ownerScope:

ownerScope Field used Checked against
personal collectionId the caller owns that collection
organization collectionIds and projectId both belong to the resolved organization

The field for the other scope is ignored.

If the target is not yours, or does not belong to the resolved organization, the call fails before any URL is minted: 404 Collection <id> not found or 404 Project not found. It is 404 rather than 403 for the same reason ownership mismatches are — see Conventions.

partCount must be between 1 and 10,000. contentType must match /^[\w.+-]+\/[\w.+-]+$/. fileName is capped at 255 characters.

Upload the parts

The client PUTs each 5 MB chunk straight to S3 in parallel and reads the ETag response header from each.

const response = await fetch(presignedUrl, {
  method: "PUT",
  body: chunk,
});
const eTag = response.headers.get("ETag");

The part URLs are valid for one hour. A single failed part can be retried against the same URL for as long as it lasts — the parts are independent, and S3 accepts a re-PUT of the same part number. Past the hour, or if ETag comes back null, the upload cannot be completed: abort it and start again.

Complete

POST /api/upload/complete
Content-Type: application/json

{
  "uploadId": "…",
  "key": "organizations/ORa7Bk9x2Q/uploads/8f1c6f2e-3d0a-4b7c-9e21-5a0d7c4b1e93-brand-mark.png",
  "contentType": "image/png",
  "parts": [{ "partNumber": 1, "eTag": "…" }],
  "ownerScope": "organization",
  "projectId": "PRa7Bk9x2Q",
  "collectionIds": ["CLa7Bk9x2Q"]
}

The backend finalises the S3 multipart upload, then persists the asset, its first version, and an asset_logs row in a single transaction. A failure at any point rolls all three back together.

Aborting

POST /api/upload/abort
Content-Type: application/json

{ "uploadId": "…", "key": "…" }

An abandoned multipart upload leaves orphaned parts that S3 bills for. Call abort on any failure — the client is the only thing that knows the upload died.

Abort itself is best-effort: if the call fails, the parts stay in S3 and nothing else will clean them up, because no server-side job tracks abandoned uploads. Retry it once, then move on rather than blocking the user. A bucket lifecycle rule to expire incomplete multipart uploads would make this self-healing and does not exist yet.

A malformed or foreign key returns 400 Invalid upload key; a key whose prefix does not match the declared ownerScope returns 400 Upload key does not match owner scope.

Uploading a new version

Pass an existing assetId and an optional changeNote to complete, and the pipeline appends an AssetVersion rather than creating a new asset:

{
  "uploadId": "…",
  "key": "…",
  "contentType": "image/png",
  "parts": [{ "partNumber": 1, "eTag": "…" }],
  "assetId": "ASa7Bk9x2Q",
  "changeNote": "Tightened the wordmark spacing"
}

asset_versions has a unique constraint on (asset, version_number), so two concurrent version uploads cannot both claim the same number — the loser surfaces as a retryable 409.

Collections are optional

collectionId is nullable throughout the upload path. An asset can exist attached only to an organization, or to a project, with no collection at all. Both halves support this: the backend persists assets with no collection, and the SPA allows uploads without choosing one.

What lands in the database

Table Row
assets name, type, short + long URL, current_version: 1, and whichever of collection_id / owner_user_id / organization_id / project_id / created_by_user_id apply
asset_versions version_number, s3_key, asset_short_url, asset_long_url, optional change_note
asset_logs activity_type: created (or version_added) with a human-readable message
activity_logs written separately by the interceptor, after the response, not in the transaction

Was this page helpful?