dimah-s3v0.4.1
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.

Need a working reference? See 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 1.x (RC) is supported alongside 0.44 / 0.45. Use the 1.x tab below on RC; requires FumaDB 0.5+.

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 } from "@dimah-s3/server";
import { db } from "@dimah-s3/db";
import { dimahS3Db } from "@/lib/dimah-s3-db";

export const s3 = dimahS3({
  s3: s3Client,
  defaultBucket,
  upload: { enabled: true },
  multipart: { enabled: true },
  download: { enabled: true },
  delete: { enabled: true },
  plugins: [
    db({
      client: dimahS3Db,
      resolveScope: async (request) => {
        const session = await getSession(request);
        return session ? `user:${session.userId}` : null;
      },
    }),
  ],
});

// Server-side store (same instance as s3.context.db)
s3.db.objects.listByScope({ scope: "user:123" });

Plugin hooks merge ahead of your feature hooks. You still enable each feature (upload, delete, …) yourself — the plugin does not turn them on.

OptionDefaultMeaning
pendingTtlMs24hSets expiresAt when creating pending rows
deleteMode"soft"After api.delete, keep or drop the DB row

Delete behavior

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

When that path runs:

  1. S3DeleteObject removes the object for real
  2. Database — controlled by deleteMode (default "soft")
deleteModeS3 objectDatabase row
"soft"permanently deletedkept as status: "deleted" with deletedAt set
"hard"permanently deletedrow removed entirely

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

Soft delete is not a recycle bin. The file is gone from S3; the row is only kept for audit / history.

db({
  client: dimahS3Db,
  resolveScope,
  deleteMode: "soft", // default — omit this line for the same behavior
});

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.

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