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 fumadbSchema
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.
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+.
import { drizzle } from "drizzle-orm/better-sqlite3"; // or your driver
import * as schema from "./db/dimah-s3";
export const db = drizzle(client, { schema });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):
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.
| Option | Default | Meaning |
|---|---|---|
pendingTtlMs | 24h | Sets 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:
- S3 —
DeleteObjectremoves the object for real - 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 |
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:
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.tsgenerate 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 |