2.0 beta — new releases can include breaking changes. Stable 1.x docs
dimah-s3v2.0.0-beta.0
Database

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.

EndpointBrowser actionoperation
GET /db/objectslistObjectslist
GET /db/objectgetObjectread
PATCH /db/objectupdateObjectupdate
POST /db/foldercreateFolderupdate
DELETE /db/folder?path=deleteFolderupdate

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.

  • folder lists one folder: "" is the root, and a path such as reports/2026 is a folder. Omit it for a flat list of every folder.
  • search keeps rows whose filename contains the text. It runs in the database (LIKE on 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.
  • contentType is an exact type, or type/* for every subtype (image/*).
  • sort takes { field, direction }, with field set to createdAt, updatedAt, size, or filename. The default is createdAt, newest first. Ties break on the row id, and rows with no value (a pending row's size) come last in both directions.
  • nextCursor is null on 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" });
  • createFolder creates the missing ancestors too. An existing folder is FOLDER_EXISTS (409).
  • deleteFolder removes an empty folder. A folder with child folders, or with active or pending objects, is FOLDER_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):

lib/s3.ts
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: null clears them, without the schema.
  • Without attributes.schema, a PATCH with attributes is a VALIDATION_ERROR.
  • The access check runs first, so a caller without access never reaches your schema. Then a schema failure, or attributes larger than maxBytes as JSON (default 16 KiB), is a VALIDATION_ERROR (400).
  • toClient maps the stored attributes to what the browser sees. Without it, the browser sees all of them. authorize always 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:

  1. 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.
  2. 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.

Browser

Register dbClient() once. In a client component, s3Client.useApi() gives you api.db:

lib/s3-client.ts
"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. parent is a folder path.
  • Uploads from the list go to the open folder (uploadOptions).
  • "New folder" calls api.db.createFolder.
  • The list's search becomes search, and its sort maps name to filename. size and updatedAt keep their names.
  • usage is the scope total with its caps, not the open folder's.

Pass folders: false for a flat list with no folder actions.

components/files.tsx
"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

On this page