# Setup (https://s3.dimah.dev/docs/db/setup)



This guide assumes you already have Drizzle, Prisma, or Kysely connected to
a database. A working reference is
[`examples/with-db`](https://github.com/dimah-kz/dimah-s3/tree/main/examples/with-db)
(Next.js + Drizzle + SQLite).

<div className="fd-steps">
  <div className="fd-step">
    ### Install [#install-step]

    <CodeBlockTabs defaultValue="npm">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm i @dimah-s3/db fumadb
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add @dimah-s3/db fumadb
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add @dimah-s3/db fumadb
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add @dimah-s3/db fumadb
        ```
      </CodeBlockTab>
    </CodeBlockTabs>
  </div>

  <div className="fd-step">
    ### Schema [#schema-step]

    Add the `storage_object` table (including the recommended indexes), or
    [generate it with the CLI](#cli).

    <Tabs items="[&#x22;Drizzle&#x22;, &#x22;Prisma&#x22;, &#x22;Kysely&#x22;]">
      <Tab value="Drizzle">
        CLI / Drizzle output imports `fumadb/cuid` — alias it to
        `@paralleldrive/cuid2` in your `tsconfig` paths.

        ```ts title="db/dimah-s3.ts"
        import {
          sqliteTable,
          text,
          blob,
          integer,
          uniqueIndex,
          index,
        } from "drizzle-orm/sqlite-core";
        import { createId } from "fumadb/cuid";

        export const storageObject = sqliteTable(
          "storage_object",
          {
            id: text("id", { length: 255 })
              .primaryKey()
              .notNull()
              .$defaultFn(() => createId()),
            scope: text("scope").notNull(),
            bucket: text("bucket").notNull(),
            key: text("key").notNull(),
            contentType: text("content_type"),
            size: blob("size", { mode: "bigint" }),
            eTag: text("e_tag"),
            filename: text("filename"),
            status: text("status").notNull(),
            metadata: blob("metadata", { mode: "json" }),
            acl: text("acl"),
            uploadId: text("upload_id"),
            declaredSize: blob("declared_size", { mode: "bigint" }),
            confirmedAt: integer("confirmed_at", { mode: "timestamp" }),
            expiresAt: integer("expires_at", { mode: "timestamp" }),
            createdAt: integer("created_at", { mode: "timestamp" })
              .notNull()
              .defaultNow(),
            updatedAt: integer("updated_at", { mode: "timestamp" })
              .notNull()
              .defaultNow(),
            deletedAt: integer("deleted_at", { mode: "timestamp" }),
          },
          (table) => [
            uniqueIndex("storage_object_bucket_key_uk").on(table.bucket, table.key),
            // Recommended — FumaDB `generate` does not emit these yet.
            index("storage_object_scope_status_created_idx").on(
              table.scope,
              table.status,
              table.createdAt,
            ),
            index("storage_object_status_expires_idx").on(
              table.status,
              table.expiresAt,
            ),
            index("storage_object_status_created_idx").on(
              table.status,
              table.createdAt,
            ),
          ],
        );

        export const private_dimah_s3_settings = sqliteTable(
          "private_dimah_s3_settings",
          {
            id: text("id", { length: 255 }).primaryKey().notNull(),
            version: text("version", { length: 255 }).notNull().default("1.0.0"),
          },
        );
        ```
      </Tab>

      <Tab value="Prisma">
        ```prisma title="prisma/schema.prisma"
        generator client {
          provider = "prisma-client-js"
        }

        datasource db {
          provider = "postgresql"
        }

        model StorageObject {
          id           String    @id @map("id") @db.VarChar(255)
          scope        String    @map("scope")
          bucket       String    @map("bucket")
          key          String    @map("key")
          contentType  String?   @map("content_type")
          size         BigInt?   @map("size")
          eTag         String?   @map("e_tag")
          filename     String?   @map("filename")
          status       String    @map("status")
          metadata     Json?     @map("metadata")
          acl          String?   @map("acl")
          uploadId     String?   @map("upload_id")
          declaredSize BigInt?   @map("declared_size")
          confirmedAt  DateTime? @map("confirmed_at")
          expiresAt    DateTime? @map("expires_at")
          createdAt    DateTime  @default(now()) @map("created_at")
          updatedAt    DateTime  @updatedAt @map("updated_at")
          deletedAt    DateTime? @map("deleted_at")

          @@unique([bucket, key], map: "storage_object_bucket_key_uk")
          @@index([scope, status, createdAt], map: "storage_object_scope_status_created_idx")
          @@index([status, expiresAt], map: "storage_object_status_expires_idx")
          @@index([status, createdAt], map: "storage_object_status_created_idx")
          @@map("storage_object")
        }

        model PrivateDimahS3Settings {
          id      String @id @map("id") @db.VarChar(255)
          version String @default("1.0.0") @map("version") @db.VarChar(255)

          @@map("private_dimah_s3_settings")
        }
        ```
      </Tab>

      <Tab value="Kysely">
        There is no committed schema file for Kysely. Migrate with the CLI, then add
        the [indexes](#cli) with SQL:

        ```bash
        node --import tsx scripts/db-cli.mts migrate
        ```
      </Tab>
    </Tabs>
  </div>

  <div className="fd-step">
    ### Client [#client-step]

    Wrap your ORM connection with a FumaDB adapter, then create the dimah-s3 DB
    client:

    <Tabs items="[&#x22;Drizzle&#x22;, &#x22;Prisma&#x22;, &#x22;Kysely&#x22;]">
      <Tab value="Drizzle">
        drizzle-orm 0.44 / 0.45 and 1.x (RC) are both supported. 1.x needs FumaDB
        0.5 or later.

        <Tabs items="[&#x22;0.x&#x22;, &#x22;1.x&#x22;]">
          <Tab value="0.x">
            ```ts title="lib/db.ts"
            import { drizzle } from "drizzle-orm/better-sqlite3"; // or your driver
            import * as schema from "./db/dimah-s3";

            export const db = drizzle(client, { schema });
            ```
          </Tab>

          <Tab value="1.x">
            ```ts title="lib/db.ts"
            import { defineRelations } from "drizzle-orm";
            import { drizzle } from "drizzle-orm/better-sqlite3"; // or your driver
            import { storageObject, private_dimah_s3_settings } from "./db/dimah-s3";

            export const db = drizzle({
              client,
              relations: defineRelations({ storageObject, private_dimah_s3_settings }),
            });
            ```
          </Tab>
        </Tabs>

        ```ts title="lib/dimah-s3-db.ts"
        import { drizzleAdapter } from "fumadb/adapters/drizzle";
        import { DimahS3DB } from "@dimah-s3/db";
        import { db } from "@/lib/db";

        export const dimahS3Db = DimahS3DB.client(
          drizzleAdapter({ db, provider: "sqlite" }), // or "postgresql" | "mysql"
        );
        ```
      </Tab>

      <Tab value="Prisma">
        ```ts title="lib/dimah-s3-db.ts"
        import { prismaAdapter } from "fumadb/adapters/prisma";
        import { DimahS3DB } from "@dimah-s3/db";
        import { prisma } from "@/lib/db";

        export const dimahS3Db = DimahS3DB.client(
          prismaAdapter({ prisma, provider: "postgresql" }),
        );
        ```
      </Tab>

      <Tab value="Kysely">
        ```ts title="lib/dimah-s3-db.ts"
        import { kyselyAdapter } from "fumadb/adapters/kysely";
        import { DimahS3DB } from "@dimah-s3/db";
        import { db } from "@/lib/db";

        export const dimahS3Db = DimahS3DB.client(
          kyselyAdapter({ db, provider: "postgresql" }),
        );
        ```
      </Tab>
    </Tabs>
  </div>

  <div className="fd-step">
    ### Register the plugin [#register-the-plugin-step]

    Pass `db()` in `plugins`. `resolveScope` must return a stable ownership
    string, or `null` to reject unauthenticated callers (`401`):

    ```ts title="lib/s3.ts"
    import { dimahS3, route } from "@dimah-s3/server";
    import { db } from "@dimah-s3/db";
    import { dimahS3Db } from "@/lib/dimah-s3-db";

    export const s3 = dimahS3({
      client: awsS3,
      bucket: process.env.S3_BUCKET!,
      plugins: [
        db({
          client: dimahS3Db,
          resolveScope: async (request) => {
            const session = await getSession(request);
            return session ? `user:${session.userId}` : null;
          },
        }),
      ],
      routes: {
        avatar: route({
          upload: {
            fileTypes: ["image/*"],
            maxFileSize: 2 * 1024 * 1024,
          },
          download: true,
          delete: true,
        }),
      },
    });
    ```

    The same store is available as `s3.db` (and `s3.context.db`):

    ```ts
    s3.db.objects.listByScope({ scope: "user:123" });
    ```

    <Callout type="warn" title="Features stay opt-in">
      Plugin hooks merge onto every route unless the route sets
      `plugins: { db: false }`. You still enable each operation on the route
      yourself — the plugin does not turn them on. Enable download or delete
      on this **same** route when those callers share these objects
      ([combining features](https://s3.dimah.dev/docs/server/routes#combining-features)).
    </Callout>
  </div>
</div>

### Plugin options [#plugin-options]

```ts
import type { DbPluginOptions, DbPluginContext } from "@dimah-s3/db";
```

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

### Delete behavior [#delete-behavior]

App deletes always go through the normal server delete path (`api.delete`,
the Delete button, `useDelete`, or your own delete hooks). Do not call
`s3.db.objects.softDelete` or `hardDelete` for that. Those store helpers
only update the database. They are not a replacement for deleting the S3
object.

When that path runs:

1. S3 — `DeleteObject` removes the object
2. Database — controlled by `deleteMode` (default `"soft"`)

| `deleteMode` | S3 object           | Database row                                     |
| ------------ | ------------------- | ------------------------------------------------ |
| `"soft"`     | permanently deleted | kept as `status: "deleted"` with `deletedAt` set |
| `"hard"`     | permanently deleted | row removed entirely                             |

Omit `deleteMode` and the row stays as `deleted` after S3 removal. Listings
hide soft-deleted rows by default.

<Callout type="warn" title="Not a recycle bin">
  Soft delete is not a recycle bin. The file is gone from S3. The row is kept
  for audit and history.
</Callout>

```ts
db({
  client: dimahS3Db,
  resolveScope,
  deleteMode: "soft", // default — you can omit this line
});
```

<Accordions>
  <Accordion title="When to use hard delete">
    Use `deleteMode: "hard"` when every delete should also erase the DB row
    (for example GDPR-style erasure). To prune old soft-deleted audit rows
    later, call `s3.db.objects.hardDelete({ bucket, key })` from a script or
    job — that helper only removes the row; it does not delete from S3.
  </Accordion>
</Accordions>

## CLI [#cli]

Generate adapter-specific schema (or run migrations) through FumaDB:

```ts title="scripts/db-cli.mts"
import { DimahS3DB } from "@dimah-s3/db";
import { runCli } from "@dimah-s3/db/cli";
import { drizzleAdapter } from "fumadb/adapters/drizzle";

void runCli(
  DimahS3DB.client(drizzleAdapter({ db: {} as never, provider: "sqlite" })),
);
```

```bash
node --import tsx scripts/db-cli.mts generate latest -o ./db/dimah-s3.ts
```

`generate` overwrites the output file. Re-add secondary indexes afterward,
or keep them in a separate migration.

| Index                                     | Columns                        |
| ----------------------------------------- | ------------------------------ |
| `storage_object_scope_status_created_idx` | `scope`, `status`, `createdAt` |
| `storage_object_status_expires_idx`       | `status`, `expiresAt`          |
| `storage_object_status_created_idx`       | `status`, `createdAt`          |
