# API (https://v2.s3.dimah.dev/docs/db/api)

## Server [#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:

```ts
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](https://v2.s3.dimah.dev/docs/db/setup#delete-behavior). `objects.update` and the
folder methods check paths the way the HTTP endpoints do (`FOLDER_INVALID`),
and a move creates the target folder:

```ts
await s3.db.objects.update(
  { bucket: "my-bucket", key, scope }, // only this scope's row
  { folder: "reports/2026", attributes: { starred: true } },
);
```

### DbObjects [#dbobjects]

```ts
import type { DbObjects } from "@dimah-s3/db";
```

<AutoTypeTable
  path="packages/db/src/plugin/public-stores.ts"
  name="DbObjects"
/>

### DbFolders [#dbfolders]

```ts
import type { DbFolders } from "@dimah-s3/db";
```

<AutoTypeTable
  path="packages/db/src/store/folder-store.ts"
  name="StorageFolderStore"
/>

## HTTP endpoints [#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 [#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.

```ts
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,
});
```

<Callout title="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.
</Callout>

## Folders [#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`](#browser) sets it for uploads
into the open folder. With your own `useUpload`, pass it per call:

```ts
await upload.handleFiles(files, {
  uploadOptions: { metadata: { folder: "reports/2026" } },
});
```

```ts
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 [#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`.

```ts
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]

Attributes are your own JSON on each row, such as a description or tags.
Enable them with a [Standard Schema](https://standardschema.dev) (Zod,
Valibot, ArkType):

```ts title="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:

```ts
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`:

```ts
dbClient<{ description?: string; tags?: string[] }>();
```

<AutoTypeTable
  path="packages/db/src/types/plugin.ts"
  name="DbAttributesOptions"
/>

## Quota [#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):

```ts
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](https://v2.s3.dimah.dev/docs/db/setup#scope) 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.

<Accordions>
<Accordion title="How should I check quotas for a large scope?">

`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.

</Accordion>
</Accordions>

## Browser [#browser]

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

```ts title="lib/s3-client.ts"
"use client";

import { createS3Client } from "@dimah-s3/react";
import { dbClient } from "@dimah-s3/db/client"; // [!code ++]

export const s3Client = createS3Client({
  basePath: "/api/s3",
  plugins: [dbClient()], // [!code ++]
});
```

<AutoTypeTable path="packages/db/src/types/client.ts" name="DbClientActions" />

`dbObjectSource(api.db, options)` turns those actions into a
[`useObjectList`](https://v2.s3.dimah.dev/docs/react/hooks/object-list) 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.

```tsx title="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`](https://v2.s3.dimah.dev/docs/react/ui/components/object-list) renders the same
state with folders, upload, and usage: `<ObjectList list={list} />`.

<AutoTypeTable
  path="packages/db/src/types/client.ts"
  name="DbObjectSourceOptions"
/>
