API
List, move, and annotate objects, manage folders, and cap usage, on the server and in the browser.
Server
With the plugin registered, s3.db.objects and s3.db.folders are
available in your routes, jobs, and scripts. They take the scope as an
argument, so they can read any scope:
const scope = "user:123";
const page = await s3.db.objects.list({
scope,
folder: "reports", // null is the root; omit for every folder
sort: { field: "updatedAt", direction: "desc" },
limit: 50,
});
const next = await s3.db.objects.list({
scope,
folder: "reports",
sort: { field: "updatedAt", direction: "desc" },
cursor: page.nextCursor,
});
const count = await s3.db.objects.count({ scope, status: "active" });
const usage = await s3.db.objects.getUsage(scope);
const object = await s3.db.objects.find({ bucket: "my-bucket", key });
const children = await s3.db.folders.children(scope, null);
await s3.db.folders.ensure(scope, "reports/2026");list returns { objects, nextCursor, firstPage }. It defaults to active
rows, 50 per page, newest first. A cursor made under other filters is
ignored, and the listing starts again at the first page (firstPage is
then true).
The stores never call S3, and the upload lifecycle stays with the plugin,
so they have no write besides update. To remove a file, use the normal
delete path. objects.update and the
folder methods check paths the way the HTTP endpoints do (FOLDER_INVALID),
and a move creates the target folder:
await s3.db.objects.update(
{ bucket: "my-bucket", key, scope }, // only this scope's row
{ folder: "reports/2026", attributes: { starred: true } },
);DbObjects
import type { DbObjects } from "@dimah-s3/db";Prop
Type
DbFolders
import type { DbFolders } from "@dimah-s3/db";Prop
Type
HTTP endpoints
db() mounts these endpoints next to the dimah-s3 routes. Each one resolves
the caller's scope from the request and runs authorize with the
operation shown. Body endpoints accept JSON only.
| Endpoint | Browser action | operation |
|---|---|---|
GET /db/objects | listObjects | list |
GET /db/object | getObject | read |
PATCH /db/object | updateObject | update |
POST /db/folder | createFolder | update |
DELETE /db/folder?path= | deleteFolder | update |
GET and PATCH /db/object take an optional bucket. It defaults to the
only bucket your routes use. When routes use several buckets, bucket is
required, and a bucket no route uses is a VALIDATION_ERROR.
Listing
GET /db/objects defaults to status: "active" and limit: 50, and
rejects a limit above 100. Unknown query keys, such as the 1.x offset
and prefix, are a VALIDATION_ERROR (400), and so is
status=purging: rows that purge has claimed are never listed.
folderlists one folder:""is the root, and a path such asreports/2026is a folder. Omit it for a flat list of every folder.searchkeeps rows whosefilenamecontains the text. It runs in the database (LIKEon SQL), where%and_act as wildcards, and the database decides case: PostgreSQL matches it exactly, SQLite ignores ASCII case, and MySQL follows the column collation.contentTypeis an exact type, ortype/*for every subtype (image/*).sorttakes{ field, direction }, withfieldset tocreatedAt,updatedAt,size, orfilename. The default iscreatedAt, newest first. Ties break on the row id, and rows with no value (a pending row'ssize) come last in both directions.nextCursorisnullon the last page. A cursor made under other filters is ignored, and the list starts again at the first page.
The first page also carries usage, limits, and, when folder is set,
the direct child folders (at most 1000; with search, only those whose
name contains it, ignoring case). The response never names the caller's
scope: it stays on the server.
const page = await api.db.listObjects({
folder: "",
search: "invoice",
sort: { field: "size", direction: "desc" },
});
const more = await api.db.listObjects({
folder: "",
search: "invoice",
sort: { field: "size", direction: "desc" },
cursor: page.nextCursor ?? undefined,
});Scope is resolved on the server
No browser action takes a scope. The plugin resolves it from the request with
resolveScope. To read another scope, use s3.db.objects on the server.
Folders
Folders are logical: a row's folder column and the storage_folder table.
They never change the S3 key. Empty folders are listed too.
A folder path is /-separated, with no leading or trailing /. Each name
is 1 to 255 characters, not . or .., and has no control characters. The
whole path is at most 1024 characters and 32 levels deep. Paths are NFC-normalized. Anything
else is FOLDER_INVALID (400). In JSON and in types the root is null;
on the wire (?folder=, clientMetadata.folder) it is "".
An upload goes to the folder in clientMetadata.folder. Missing folders are
created along the way. dbObjectSource sets it for uploads
into the open folder. With your own useUpload, pass it per call:
await upload.handleFiles(files, {
uploadOptions: { metadata: { folder: "reports/2026" } },
});await api.db.createFolder({ path: "reports/2026" }); // and "reports"
await api.db.deleteFolder({ path: "reports/2026" });createFoldercreates the missing ancestors too. An existing folder isFOLDER_EXISTS(409).deleteFolderremoves an empty folder. A folder with child folders, or with active or pending objects, isFOLDER_NOT_EMPTY(409). A missing folder is not an error.- Neither accepts the root (
"").
Update an object
PATCH /db/object renames an object, moves it to another folder, or sets
its attributes. Omitted fields stay as they are. Only active objects can be
updated; anything else is OBJECT_NOT_FOUND.
const { object } = await api.db.updateObject({
key: file.key,
filename: "Q3 report.pdf",
folder: "reports/2026", // null moves it to the root
});A move checks the target folder like an upload does: it validates the path,
runs authorize with that folder, and creates the folder when it is
missing. Renames and moves change only the row: the S3 key stays as it is.
Attributes
Attributes are your own JSON on each row, such as a description or tags. Enable them with a Standard Schema (Zod, Valibot, ArkType):
import * as z from "zod";
db({
client: dimahS3Db,
resolveScope,
attributes: {
schema: z.object({
description: z.string().max(500).optional(),
tags: z.array(z.string().max(32)).max(10).optional(),
}),
},
});PATCH /db/object with attributes replaces them after validation:
await api.db.updateObject({ key: file.key, attributes: { tags: ["q3"] } });attributes: nullclears them, without the schema.- Without
attributes.schema, a PATCH withattributesis aVALIDATION_ERROR. - The access check runs first, so a caller without access never reaches
your schema. Then a schema failure, or attributes larger than
maxBytesas JSON (default 16 KiB), is aVALIDATION_ERROR(400). toClientmaps the stored attributes to what the browser sees. Without it, the browser sees all of them.authorizealways sees them as stored.
Type the browser side by passing the client attributes to dbClient:
dbClient<{ description?: string; tags?: string[] }>();Prop
Type
Quota
quota caps the bytes and files of a scope. Pass fixed caps, or a function
that returns them per request (null for no cap):
db({
client: dimahS3Db,
getSession,
resolveScope: ({ session }) => (session ? `user:${session.userId}` : null),
quota: ({ session }) =>
session?.plan === "pro"
? { maxBytes: 10 * 1024 ** 3 }
: { maxBytes: 1024 ** 3, maxFiles: 500 },
});The function gets the scope context plus the
resolved scope. The quota is checked twice:
- At upload, against the scope's usage: active sizes plus the declared sizes of pending uploads that have not expired. The row the upload replaces is left out.
- At confirm, against the verified size from HeadObject. When that fails, the server deletes the object and the plugin removes its row. A confirm replay is not checked again.
A refusal is QUOTA_EXCEEDED (403), with params.limit set to "bytes"
or "files".
The first page of GET /db/objects carries usage and limits (the caps
for the scope, or null). dbObjectSource merges them into the list's
usage, so ObjectListUsage shows the meter against the server's caps.
getUsage sums every live row of the scope on each call. That is fine for
demos and light use, but not for frequent uploads on large scopes. Keep a
counter of your own in that case, and return caps from the quota function
that account for it.
Browser
Register dbClient() once. In a client component, s3Client.useApi()
gives you api.db:
"use client";
import { createS3Client } from "@dimah-s3/react";
import { dbClient } from "@dimah-s3/db/client";
export const s3Client = createS3Client({
basePath: "/api/s3",
plugins: [dbClient()],
});Prop
Type
dbObjectSource(api.db, options) turns those actions into a
useObjectList source:
- Each page lists one folder, and the first page carries its child folders.
parentis a folder path. - Uploads from the list go to the open folder (
uploadOptions). - "New folder" calls
api.db.createFolder. - The list's
searchbecomessearch, and its sort mapsnametofilename.sizeandupdatedAtkeep their names. usageis the scope total with its caps, not the open folder's.
Pass folders: false for a flat list with no folder actions.
"use client";
import { dbObjectSource } from "@dimah-s3/db/client";
import { useObjectList } from "@dimah-s3/react";
import { s3Client } from "@/lib/s3-client";
export function Files() {
const api = s3Client.useApi();
const list = useObjectList({
...dbObjectSource(api.db, { route: "files" }),
queryKey: ["files"],
});
if (list.isLoading) return <p>Loading…</p>;
return (
<ul>
{list.entries.map((entry) => (
<li key={entry.key}>{entry.name}</li>
))}
{list.hasMore ? (
<li>
<button type="button" onClick={() => void list.loadMore()}>
More
</button>
</li>
) : null}
</ul>
);
}ObjectList renders the same
state with folders, upload, and usage: <ObjectList list={list} />.
Prop
Type