Scopes — the contract
Declare once where a file goes, who may read it, how big it may be and what it replaces. Both sides validate against the same definition.
Scopes — the contract
A scope is a named destination: the single source of truth for where a file lands, who may read it, and what is accepted there. The registry is a plain object the client and the server both import, which is what makes the two validations agree by construction.
Defining scopes
import { defineScopes, MB } from 'uploaderkit'
export const scopes = defineScopes({
'user-avatar': {
path: userId => `Users/${userId}/avatar`,
visibility: 'public',
accept: ['png', 'jpg', 'jpeg', 'webp'],
maxBytes: 5 * MB,
category: 'image',
compress: { maxWidth: 512, quality: 0.8, stripExif: true },
},
})| Field | Meaning |
|---|---|
path | (entityId, file) => string — the storage key. You own collisions and folder shape. |
visibility | 'public' (direct URL) or 'private' (signed expiring URL only). |
accept | Extensions allowed here. Narrower than the category preset, never wider. |
maxBytes | Hard ceiling. KB / MB / GB helpers are exported. |
category | 'image' | 'pdf' | 'document' | 'data' | 'video' | 'audio' | 'certificate' | 'key' | 'any'. Picks the preset that decides whether magic numbers are read. |
encrypt | Hand the bytes to the app's cipher before they leave the server. |
compress | Client-side image pipeline: maxWidth, maxHeight, quality, stripExif (default true). |
maxFiles | How many files one entity may hold here. Default 1. The uploader derives multiple from it. |
replace | What an upload removes. Derived by default — see Replace. |
prefix | (entityId) => string — objects an 'entity' replace may delete. Defaults to the folder of the resolved key. |
metadata | Free-form tags forwarded to the provider when it supports them. |
Replace — never leave a dead file
Object storage does not clean up after itself. A scope whose key carries the
file name writes a NEW object every time, so re-uploading a logo leaves the
previous one paying rent forever. replace is what decides that, and its
default is derived so there is no prop to forget:
| The scope | Derived replace | Why |
|---|---|---|
maxFiles: 1 (default), key carries file.name | 'entity' | Every upload lands on a new key — the old object must be swept. |
maxFiles: 1, key ignores file.name | 'key' | The key is stable, so the provider overwrites in place already. |
maxFiles > 1 | 'key' | A collection: siblings are the point. |
Declare it explicitly only to opt out — replace: false keeps every version.
The 'entity' sweep runs after a successful put and deletes everything
under the entity's prefix that is not the new key. Two guards keep it from
reaching too far, both at defineScopes time:
replace: 'entity'together withmaxFiles > 1throws. A scope cannot hold a collection and erase it on every upload.- Two scopes whose folders overlap throw when either sweeps, so an avatar
upload can never delete the same user's documents. Give each its own folder,
or narrow one with
prefix.
defineScopes({
// One file, swept: uploading `new.png` deletes `old.png`.
logo: { path: (id, file) => `Companies/${id}/logo/${file.name}` /* … */ },
// A collection: `maxFiles` alone switches the semantics.
expediente: {
path: (id, file) => `Companies/${id}/docs/${file.name}`,
maxFiles: 10 /* … */,
},
// Keeps every version on purpose.
audit: {
path: (id, file) => `Companies/${id}/audit/${file.name}`,
replace: false /* … */,
},
})defineScopes returns a ScopeRegistry: names, get(name), has(name) and
accept(name) — the last one being the ready-made string for <input accept>.