dimah-s3v1.5.5
Database

Setup

Install @dimah-s3/db, add the 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 {
  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"),
  },
);

Client

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

drizzle-orm 0.44 / 0.45 and 1.x (RC) are both supported. 1.x needs FumaDB 0.5 or later.

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

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

Plugin options

import type { DbPluginOptions, DbPluginContext } 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")
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", // default — you can omit this line
});

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.

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

On this page