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 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 0.44 / 0.45 and 1.x (RC) are both supported. 1.x needs FumaDB 0.5 or later.
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, 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:
- S3 —
DeleteObjectremoves the object - 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.
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
});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 |