2.0 beta is ready. Install with @beta.
dimah-s3v1.7.2
Database

Database Setup

Install @dimah-s3/db, add its schema, and register the db() plugin.

This guide assumes you already have Drizzle, Prisma, or Kysely connected to a database. A working reference is examples/with-db (Next.js + Drizzle + SQLite).

Install

npm i @dimah-s3/db fumadb

Schema

Add the storage_object table (including the recommended indexes), or generate it with the CLI.

CLI / Drizzle output imports fumadb/cuid — alias it to @paralleldrive/cuid2 in your tsconfig paths.

db/dimah-s3.ts
import { defineRelations, sql } from "drizzle-orm";
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(),
    route: text("route").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()
      .default(sql`(unixepoch())`),
    updatedAt: integer("updated_at", { mode: "timestamp" })
      .notNull()
      .default(sql`(unixepoch())`),
    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_scope_route_idx").on(table.scope, table.route),
    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"),
  },
);

export const relations = defineRelations({
  storageObject,
  private_dimah_s3_settings,
});

Database client

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

drizzle-orm 1.x (the schema above) and 0.44 / 0.45 are both supported. 1.x needs FumaDB 0.6 or later, and the client takes relations from defineRelations().

lib/db.ts
import { drizzle } from "drizzle-orm/better-sqlite3"; // or your driver
import { relations } from "./db/dimah-s3";

export const db = drizzle({
  client,
  relations,
});
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"
);

Register the plugin

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

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):

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

Scope context

resolveScope receives a second argument with the values the endpoint already validated: operation ("upload", "confirm", "multipart", "download", "delete", or "list"), route, the object key, and, on upload, the client's clientMetadata. A scope that depends on the key does not have to parse the request. A batch delete resolves once per key, and one request never calls the resolver twice for the same operation and key.

lib/s3.ts
db({
  client: dimahS3Db,
  resolveScope: async (request, { key }) => {
    const session = await getSession(request);
    if (!session) return null;
    // Keys look like `{route}/{kind}/{id}/…`.
    const kind = key?.split("/")[1]; 
    return kind === "org" ? `org:${session.orgId}` : `user:${session.userId}`;
  },
});

Prop

Type

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 operations).

Plugin options

import type { DbPluginOptions } from "@dimah-s3/db";

Prop

Type

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")

Delete is idempotent. A pending row whose object never landed (a closed tab, a network error, an object a lifecycle rule removed) is cleaned up the same way, so it stops counting toward quota before the purge job runs. A pending multipart upload is aborted by its uploadId.

deleteModeS3 objectDatabase row
"soft"permanently deletedkept as status: "deleted" with deletedAt set
"hard"permanently deletedrow removed entirely

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

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.

db({
  client: dimahS3Db,
  resolveScope,
  deleteMode: "soft", 
});

Frequently asked questions

CLI

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

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" })),
);
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. On drizzle-orm 1.x, also replace .defaultNow() with sql`(unixepoch()). defaultNow() is deprecated and stores milliseconds, while mode: "timestamp" reads seconds.

IndexColumns
storage_object_scope_status_created_idxscope, status, createdAt
storage_object_scope_route_idxscope, route
storage_object_status_expires_idxstatus, expiresAt
storage_object_status_created_idxstatus, createdAt

On this page