Upgrade to 2.0 beta
Move a 1.7 app onto the 2.0 beta. Betas can include breaking changes until 2.0.0.
The 2.0 beta changes the wire protocol, the hook contracts, and the components
of every @dimah-s3/* package. Upgrade them together: @dimah-s3/ui and
@dimah-s3/db pin their @dimah-s3/* peers to the same version, and a 1.7
client does not work against a 2.0 beta server.
This line is 2.0.0-beta. A new beta ships every few days, and it can include
breaking changes until 2.0.0. Install with the beta dist-tag. @latest
stays on 1.x. Each package's changelog lists every change; this page covers
what most apps need.
Install
npm i @dimah-s3/server@beta @dimah-s3/core@beta @dimah-s3/react@beta @dimah-s3/ui@beta @fuma-translate/react@dimah-s3/reactand@dimah-s3/uineed React 19.2 or later, as@fuma-translate/reactdoes.@dimah-s3/coreis now a direct dependency. React no longer re-exports it.@fuma-translate/reactis a peer dependency of@dimah-s3/react. Install it yourself.- With
db(), add@dimah-s3/db@betaand follow Database below.
Server
Set the upload secret
Presign and multipart init now return a signed upload ticket, and confirm accepts only a ticket the server signed. Set a secret of at least 32 characters, the same on every instance:
# openssl rand -base64 32
DIMAH_S3_SECRET="your-generated-secret"dimahS3() reads DIMAH_S3_SECRET on the first upload. Pass secret to
read it from somewhere else, or an array to rotate: the first value signs,
and every value verifies.
export const s3 = dimahS3({
// ...
secret: [process.env.DIMAH_S3_SECRET!, process.env.DIMAH_S3_SECRET_OLD!],
});Without a secret, uploads return 500 unless NODE_ENV is development or
test, where a random secret is used with a warning.
Configure the S3Client checksums
The AWS SDK default (WHEN_SUPPORTED) breaks presigned uploads. Every
S3Client you pass must set:
export const awsS3 = new S3Client({
// ...
requestChecksumCalculation: "WHEN_REQUIRED",
});With the SDK default, the first presign logs the problem and returns 500.
Update the route config
upload.methoddefaults to"PUT", which every provider supports, including R2. Setmethod: "POST"to keep presigned POST.download.expiresInis nowdownload.maxExpiresIn.upload.replaceis now the db route optionplugins: { db: { replace: "overwrite" } }.- In
upload.object,folderis nowkeyFolder. - Downloads open inline only for raster images, PDF (presign mode), audio,
video, and plain text. Set
download.inlineTypesto change the list. upload.expiresIn,upload.confirmWindow,upload.maxFileSize,upload.multipart.partSize,upload.multipart.ticketExpiresIn, anddownload.maxExpiresInmust be positive integers. A fractional value now throws atdimahS3()./fileserves only routes withdownload.mode: "proxy". A presign-mode route returns404 FEATURE_DISABLEDthere.- Routes on different buckets may share a
keyPrefix; on one bucket they still may not overlap.
Rewrite guards and errors
The errors export is removed. Replace throw errors.unauthorized() and the
other helpers with throw s3Error("UNAUTHORIZED") and another catalog code. A plain
Error thrown from a hook returns 500 INTERNAL_ERROR and is logged.
A guard returns nothing to allow, or { locals } to allow and pass values to
later hooks in the same call. Any other return value, such as true, is a
config error.
import { dimahS3, route, s3Error } from "@dimah-s3/server";
declare module "@dimah-s3/server" {
interface DimahS3Locals {
userId?: string;
}
}
export const s3 = dimahS3({
// ...
guard: async ({ request }) => {
const session = await getSession(request);
if (!session) throw s3Error("UNAUTHORIZED");
return { locals: { userId: session.userId } };
},
routes: {
avatar: route({
upload: {
object: ({ locals }) => ({ keyFolder: locals.userId }),
},
}),
},
});Lifecycle on* hooks now run plugin hooks first, then yours, the same as
guards. onError is called as onError(error, { request }).
Rename the upload hooks
| 1.7 | 2.0 beta |
|---|---|
upload.onPresigned | upload.onStart (kind is post/put) |
upload.multipart.onInit | upload.onStart (kind is multipart) |
upload.multipart.onList | Removed |
StoredObjectContext | ObjectContext |
file: { fileName, ... } | file: { name, size, type } |
onDeleted({ existed }) | onDeleted without existed |
upload.onStart({ kind, uploadToken, uploadId, url, urlExpiresAt, expiresAt })
also runs for s3.put, with kind: "server".
upload.onConfirmed runs at least once per upload. A retried confirm
calls it again with the same uploadToken, so make its writes idempotent:
avatar: route({
upload: {
onConfirmed: async ({ key, uploadToken, object, locals }) => {
await db.avatar.upsert({
where: { uploadToken },
create: { uploadToken, key, size: object.size, userId: locals.userId },
update: {},
});
},
},
}),A throw from onConfirmed, including an APIError, returns 500 and keeps
the object. To reject a file after upload, throw from upload.confirmGuard
instead: that deletes the object.
Update server calls
s3.api has the browser client's shape. Each method takes the browser
payload, then { headers, request, locals } for the hooks:
import { headers } from "next/headers";
import { s3 } from "@/lib/s3";
const { url } = await s3.api.download(
{ route: "invoices", key },
{ headers: await headers() },
);The aliases deleteBatch, multipart*, and file are removed. Use
deleteMany and multipart.*.
s3.put takes name and type in place of fileName and contentType,
and moves headers to the second argument:
await s3.put({
route: "avatar",
name: "avatar.png",
type: "image/png",
body: bytes,
});definePlugin, createS3Endpoint, createS3Middleware, and isFeatureOn
are exported from @dimah-s3/server/plugins only, and the
@dimah-s3/server/api entry is gone.
Check custom proxies and CORS
- Body endpoints accept
application/jsononly and return415 UNSUPPORTED_MEDIA_TYPEotherwise. - Request bodies are capped by
maxRequestBodySize(1 MiB by default). /fileURLs are relative to the API base path unless you setbaseURL. See File proxy.- Node adapters ignore
X-Forwarded-*unless you pass{ trustProxy: true }.
React
Move imports
The root entry holds hooks, S3Provider, createS3Client, the useFormat*
hooks, and types. Protocol names come from @dimah-s3/core, and plain
functions from @dimah-s3/react/engine:
import { useUpload } from "@dimah-s3/react";
import { APIError, isS3ErrorCode } from "@dimah-s3/core";
import { createLocalStorageStore } from "@dimah-s3/react/engine"; @dimah-s3/react/engine has no "use client" directive and no React
import, so you can use it in shared and server modules. The display
formatters formatFileSize and truncateFileName live there too, with
formatSpeed and formatUploadProgress; they are not in @dimah-s3/core.
Update useUpload
Callbacks now run per file, and handleFiles resolves a result object.
onSuccess gets one result instead of an array:
const upload = useUpload({
route: "avatar",
onSuccess: (result) => setValue("avatarKey", result.key),
});
const { uploaded, failed, blocked, aborted } = await upload.handleFiles(files);onFileSuccess,onFileError,onFileProgress, andonCancelare removed. UseonSuccess(result, file),onError(error, file),onProgress(progress, file), andonSettled(result).files[].statusis nowphase, andisUploadingis nowisPending.getUploadOptionsis merged intouploadOptions, which can be a function of the file.- The
checksumoptions andmultipartPartSizeare removed. The server's route config decides both. - An invalid file no longer stops the batch, and
cancel(id)cancels one file. Passqueue: trueto append new files instead of replacing the queue. retry(id?)sends a failed file again in place. A test stub that builds a wholeUseUploadReturnmust addretryanddisabled.- Resume records from 1.7 are ignored.
Split fetch downloads
useDownload({ mode: "fetch" }) is now its own hook:
import { useFetchDownload } from "@dimah-s3/react";
const download = useFetchDownload({ route: "invoices" });onInitiated is now onSuccess(data, key), and isFetchDownload is
removed: narrow on download.mode. download(key, { fileName }) and
presign(key, options) return an outcome. A cancelled call settles with an
aborted outcome instead of onCancel.
Move the delete confirmation
useDelete keeps no confirm state. requestDelete, confirmDelete,
cancelDelete, and isConfirming are removed. Call remove(key) directly,
or let the ui Delete root show the dialog. Callbacks are
onSuccess(data, key), onError(error, key), and onSettled(result), and
removeMany(items) returns { deleted, failed, blocked }.
Read per-key state
useDelete, useDownload, and useFetchDownload keep state per key:
operations, get(key), and pendingKeys. A second call for a key that is
already running joins that call.
Rename error helpers
| 1.7 | 2.0 beta |
|---|---|
S3UploadError | APIError with a catalog code |
useFormatValidateFileError | useFormatDimahError |
formatEta | useFormatEta |
UploadProgress | TransferProgress |
FetchDownloadProgress | TransferProgress |
useObjectUrl().expiresIn | useObjectUrl().expiresAt (epoch ms) |
uploadFiles(...) | uploadFile(api, file, { route, ... }) |
An aborted upload rejects with an AbortError DOMException. Use
getUploadAbortKind from @dimah-s3/react/engine to tell a cancel from a
detach.
Translation keys changed. satisfies Partial<Translations> flags each key
that no longer exists. See Internationalization.
UI
Replace the preset components
Each control is now a root that takes the hook return, plus parts inside it.
Components no longer toast: mount one toast component per hook and one
<Toaster /> in your layout.
"use client";
import { useUpload } from "@dimah-s3/react";
import {
Upload,
UploadStatus,
UploadToasts,
UploadTrigger,
} from "@dimah-s3/ui";
export function AvatarUploader() {
const upload = useUpload({ route: "avatar" });
return (
<>
<Upload upload={upload}>
<UploadTrigger />
<UploadStatus />
</Upload>
<UploadToasts upload={upload} />
</>
);
}| 1.7 | 2.0 beta |
|---|---|
UploadButton | <Upload upload><UploadTrigger /><UploadStatus /></Upload> |
UploadDropzone upload={...} | <Upload upload><UploadDropzone /></Upload> |
MultiUploadStatus | UploadStatus |
UploadFileStatus | UploadFileItem or UploadFileList |
DownloadButton | <Download download><DownloadTrigger objectKey /></Download> |
ProgressDownloadButton | useFetchDownload with DownloadTrigger and DownloadProgress |
DeleteButton | <Delete delete><DeleteTrigger objectKey /></Delete> |
confirmTitle / confirmDescription | <Delete dialog={<DeleteConfirmDialog title description />}> |
toast props, use*Toast hooks | UploadToasts, DownloadToasts, DeleteToasts |
"use client";
import { useDelete, useDownload } from "@dimah-s3/react";
import {
Delete,
DeleteToasts,
DeleteTrigger,
Download,
DownloadToasts,
DownloadTrigger,
} from "@dimah-s3/ui";
export function InvoiceActions({ objectKey }: { objectKey: string }) {
const download = useDownload({ route: "invoices" });
const del = useDelete({ route: "invoices" });
return (
<>
<Download download={download}>
<DownloadTrigger objectKey={objectKey} />
</Download>
<Delete delete={del}>
<DeleteTrigger objectKey={objectKey} />
</Delete>
<DownloadToasts download={download} />
<DeleteToasts delete={del} />
</>
);
}UploadDropzone still shows the status inside it. For status={false},
give it only its trigger, and render UploadStatus where a status
function used to put it:
<UploadDropzone>
<UploadDropzoneTrigger />
</UploadDropzone>
<UploadStatus />Control props such as buttonClassName are now plain Button props on the
trigger, and tooltips are opt-in through tooltip. FileAttachment drops
size and orientation; size it with className. See
Upload,
Download, and
Delete.
With the shadcn registry, the items upload-button, upload-dropzone,
download-button, progress-download-button, and delete-button are
replaced by upload, download, and delete.
Rename theme overrides
Override the --dimah-s3-* tokens instead of --color-dimah-s3-*, on
:root or on any container:
:root {
--dimah-s3-primary: oklch(0.65 0.15 150);
}The secondary, secondary-foreground, and accent-foreground tokens are
removed. Tailwind utilities such as bg-dimah-s3-card are unchanged. See
Theming.
Update ObjectList
usageLimitsis removed. Limits come from the source'susage.- The context field
removeis nowdelete, andonDeletedreceives react'sDeleteResult. ObjectListItemMenuno longer takesdownload,delete, oronDeleted.- Built-in toasts step aside for your own
UploadToasts,DownloadToasts, orDeleteToasts. Passtoasts={false}to turn them off.
Errors
S3_ERROR_CODES is the one error catalog. Every APIError has a code, a
status, and typed params. Read details from error.params, not from the
message.
| 1.7 | 2.0 beta |
|---|---|
PAYLOAD_TOO_LARGE | FILE_TOO_LARGE with { size, maxFileSize } |
INVALID_KEY | KEY_INVALID |
QUOTA_EXCEEDED | QUOTA_EXCEEDED (403) with { limit } |
APIError.fromStatus(...) | s3Error(code, params) or APIError.from |
DELETE_BATCH_MAX_KEYS | DELETE_MANY_MAX_KEYS |
S3_MAX_OBJECT_KEY_LENGTH | S3_MAX_OBJECT_KEY_BYTES (1024 UTF-8 bytes) |
S3_API_ROUTES.uploadConfirm | S3_API_ROUTES.confirm |
S3_API_ROUTES.deleteBatch | S3_API_ROUTES.deleteMany |
S3_API_ROUTES.multipartPart | S3_API_ROUTES.multipartSignPart |
validateFile returns APIError | null. isAPIError and
isS3ErrorCode(error, code) match only real APIErrors, whose code and
params are set, and isS3ErrorCode narrows params to that code. The
new OBJECT_EXISTS and FOLDER_EXISTS (409) replace CONFLICT for a key
or folder that is taken; CONFLICT now means a concurrent change won. The
full list is on Errors.
Custom backends and transports
If you call the HTTP API yourself or wrote an UploadTransport:
- File fields are
name,size, andtypeon the wire. The download and/filequery parameterfileNameis nowname. confirm,multipart.signPart,multipart.listParts,multipart.complete, andmultipart.aborttake{ ticket }.listPartsis a POST, andcompletetakes noparts.- Multipart init takes no
partSize. It returnspartSize,partCount, andchecksum, andsignPartreturns the partsizeand theheadersto send. - A presigned PUT signs every header it returns. Send
headersexactly. - Every
S3Apimethod takes(payload, { signal, headers }). - In
@dimah-s3/core,DeleteBatchPayloadandDeleteBatchResponseare nowDeleteManyPayloadandDeleteManyResponse. Confirm, listParts, complete, and abort all take oneTicketPayload({ ticket }), validated byticketBodySchema. - The Zod schemas moved to
@dimah-s3/core/schema, so the browser entry ships without Zod. ImportuploadBodySchemaand the others from there.
See Custom backend.
Database
@dimah-s3/db moves to database schema 2.0.0, with logical folders, typed
attributes, and an upload token per row. Upgrade the schema before you
deploy the new server: a 1.x database throws until it is upgraded.
- Run
sql/upgrade/1.0.0-2.0.0/<dialect>.sqlfrom the package, ormigrate(client)from@dimah-s3/db/cliwith Kysely. - Remove
fumadband thefumadb/cuidpath alias from your app. Import tables from@dimah-s3/db/drizzle/<dialect>and adapters from@dimah-s3/db/adapters/<orm>. - With Drizzle, upgrade
drizzle-ormto1.0.0-rc.5or later first. 0.44 and 0.45 are no longer supported. See Setup. createObjectAccessGuardandcreateQuotaGuardare replaced by theauthorizeandquotaoptions.purgeStalePendingObjectsis replaced bys3.db.purgeStale()ands3.db.pruneDeleted().dbObjectQueryanddbObjectListAccessorsare replaced bydbObjectSource(api.db, { route }).resolveScopereceives only the caller (request,session,locals,operation,route). Move checks onkey,folder, orclientMetadataintoauthorize, which also gets the stored row.s3.db.objectskeepsfind,list,count,getUsage, andupdate. Delete files throughs3.api.delete, and prune withs3.db.pruneDeleted.purgeStalereturns{ purged, failed, skipped, more }.
Upgrade the database has the steps for each dialect and ORM.