Database Setup
Install @dimah-s3/db, create its tables, 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).
Upgrading from 1.x? The tables changed: follow Upgrade the schema first.
Install
npm i @dimah-s3/db@betaYou do not install fumadb. The adapters ship from
@dimah-s3/db/adapters/*, and the tables from @dimah-s3/db/drizzle/*,
prisma/, and sql/.
Tables
The plugin uses two tables, storage_object and storage_folder, plus a
small settings table that records the schema version (2.0.0).
The tables ship prebuilt in @dimah-s3/db/drizzle/sqlite, /pg, and
/mysql. Re-export them from your schema file so drizzle-kit creates them
next to your own tables:
import { dimahS3Tables } from "@dimah-s3/db/drizzle/sqlite"; // or /pg, /mysql
export const { storageObject, storageFolder, private_dimah_s3_settings } =
dimahS3Tables;
// Your own tables go here.Keep the three names as they are. The adapter finds the tables by these
keys, and it reads the schema version from private_dimah_s3_settings.
npx drizzle-kit pushThe tables carry the secondary indexes, so drizzle-kit creates those too.
On MySQL, the tables set the same collations as sql/mysql.sql:
utf8mb4_bin, so keys and folders compare byte for byte, and
utf8mb4_unicode_ci on filename, so search ignores case.
Database client
Wrap your ORM connection with an adapter, then create the dimah-s3 DB client:
drizzle-orm 1.x is required (1.0.0-rc.5 or later).
import { DimahS3DB } from "@dimah-s3/db";
import { drizzleAdapter } from "@dimah-s3/db/adapters/drizzle";
import Database from "better-sqlite3";
import { defineRelations } from "drizzle-orm";
import { drizzle } from "drizzle-orm/better-sqlite3"; // or your driver
import * as schema from "../db/schema";
export const db = drizzle({
client: new Database("local.db"),
relations: defineRelations(schema),
});
export const dimahS3Db = DimahS3DB.client(
drizzleAdapter({ db, provider: "sqlite" }), // or "postgresql" | "mysql"
);On PostgreSQL, the timestamp columns have no time zone. The driver must
write and read Date values the same way: node-postgres does by default.
Register the plugin
Pass db() in plugins. getSession loads the caller, and resolveScope
returns a stable owner string for them, or null to reject the request
with UNAUTHORIZED (401):
import { S3Client } from "@aws-sdk/client-s3";
import { db } from "@dimah-s3/db";
import { dimahS3, route } from "@dimah-s3/server";
import { getSession } from "@/lib/auth"; // your auth
import { dimahS3Db } from "@/lib/dimah-s3-db";
const awsS3 = new S3Client({ requestChecksumCalculation: "WHEN_REQUIRED" });
export const s3 = dimahS3({
client: awsS3,
bucket: process.env.S3_BUCKET!,
plugins: [
db({
client: dimahS3Db,
getSession: (request) => getSession(request),
resolveScope: ({ session }) =>
session ? `user:${session.userId}` : null,
}),
],
routes: {
files: route({
upload: { maxFileSize: 20 * 1024 * 1024 },
download: true,
delete: true,
}),
},
});Set DIMAH_S3_SECRET in your environment: dimahS3() reads it to sign
upload tickets, which db() binds to the
scope.
The plugin's context is on s3.db (and s3.context.db). A database at
another schema version throws on the first query. Call checkSchema at
startup to fail fast:
await s3.db.checkSchema();
const page = await s3.db.objects.list({ scope: "user:123", folder: null });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).
Scope
Every row belongs to a scope, such as user:123 or org:acme. Upload
tickets are bound to it, so a ticket from one scope cannot confirm or
complete an upload in another.
resolveScope says who is calling. It receives the request, the
session (the getSession result), the locals your guards set, the
operation, and the route when the call has one. It never sees the
target object: the scope comes from the caller, so the same caller gets the
same scope when an upload ticket is bound, used, and confirmed. Checks on
the target belong in authorize.
getSessionruns at most once perRequest.resolveScoperuns once per call, operation, and route. Twos3.apicalls that share aRequestbut pass differentlocalsresolve separately.
Prop
Type
operation | Runs for |
|---|---|
upload | presign, multipart init, and s3.put |
confirm | confirm, and the confirm step of multipart complete |
multipart | part, list, abort, and complete |
download | presigned download and GET /file |
delete | delete, and each key of deleteMany |
list | GET /db/objects |
read | GET /db/object |
update | PATCH /db/object, POST /db/folder, DELETE /db/folder |
Authorize
A row belongs to the scope that wrote it. A caller from another scope gets
OBJECT_NOT_FOUND (404), the same as for a key with no row, so nobody
learns which keys other scopes hold. (An upload to a key another scope holds
gets FORBIDDEN: upload keys are the server's choice.) authorize runs
after that check and can only narrow it: return false to reject the
request with FORBIDDEN (403).
It gets the scope context plus the resolved scope, and what the call
targets: the key, the target folder of an upload, a listing, a move, or
a folder endpoint, and on upload the client's clientMetadata. object is
the stored row when the request targets one: confirm, the multipart calls,
download, delete, read, and update. It is the server view, with scope,
s3Metadata, and the attributes as stored, not what attributes.toClient
sends the browser.
Prop
Type
An upload on a replace: "overwrite" route that replaces an active object
runs authorize with object set to that object, and folder is that
object's folder, not clientMetadata.folder.
const inArchive = (path?: string | null) =>
path === "archive" || path?.startsWith("archive/") === true;
db({
client: dimahS3Db,
getSession,
resolveScope: ({ session }) => (session ? `user:${session.userId}` : null),
// `archive/` is read-only.
authorize: ({ operation, folder, object }) => {
if (["list", "read", "download"].includes(operation)) return true;
return !inArchive(folder) && !inArchive(object?.folder);
},
});Plugin options
import type { DbPluginOptions } from "@dimah-s3/db";Prop
Type
quota and attributes are covered in the API
page.
Route options
Set per-route options under plugins: { db: … }:
routes: {
// One key per user: a new upload replaces the old avatar instead of
// failing with OBJECT_EXISTS. `requireUserId` is your auth helper.
avatar: route({
upload: {
fileTypes: ["image/*"],
object: async ({ request }) => ({
key: `${await requireUserId(request)}/avatar`,
}),
},
download: true,
plugins: { db: { replace: "overwrite" } },
}),
// Not tracked: no rows, no ownership checks.
public: route({
upload: true,
download: { guard: requireAdmin },
plugins: { db: false },
}),
},Prop
Type
With replace: "overwrite", the row stays active and keeps the previous
object's metadata until the new upload is confirmed; S3 replaces the bytes as
soon as the new PUT lands. The row keeps its folder, attributes, and
createdAt. Until then, quota counts the larger of the two sizes. A retried
confirm of the upload before it still succeeds, even after the overwrite
started.
plugins: { db: false } turns off tracking and every db hook for the route.
Its upload tickets are not bound to a scope. When such a route enables
download or delete with no route or feature guard, the plugin logs a warning
at startup: nothing then checks who owns the object.
Delete behavior
App deletes always go through the normal server delete path (api.delete,
the Delete component, useDelete, or your own delete hooks).
s3.db.objects has no delete method: it never touches S3.
When that path runs:
- S3:
DeleteObjectremoves the object. - Database:
deleteModedecides (default"soft").
A pending row whose object never landed (a closed tab, a network error)
is cleaned up the same way, so it stops counting toward the
quota before purge runs. A multipart
upload in flight is aborted first. Deleting a soft-deleted object again
succeeds, like a plain S3 delete. A key with no row is OBJECT_NOT_FOUND:
its owner cannot be checked.
deleteMode | S3 object | Database row |
|---|---|---|
"soft" | permanently deleted | kept as status: "deleted" with deletedAt set |
"hard" | permanently deleted | row removed entirely |
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: "hard",
});Frequently asked questions
Use deleteMode: "hard" when every delete should also erase the row (for
example GDPR-style erasure). To keep soft-deleted rows for a while and then
remove them, run s3.db.pruneDeleted
from a job.
Yes. s3.put runs the same hooks with kind: "server". Pass the caller's
request (or trusted locals) so resolveScope knows who it is, and
clientMetadata.folder to file it in a folder:
await s3.put(
{
route: "files",
name: "report.csv",
type: "text/csv",
body: csv,
clientMetadata: { folder: "reports" },
},
{ request },
);CLI
runCli runs the FumaDB CLI against your client, for example to generate an
ORM schema. Prefer migrate(client) to migrate: the CLI's migrate command
jumps straight to the latest schema and skips the 2.0.0 indexes. Only
migrate:up, one version at a time, runs them.
import { runCli } from "@dimah-s3/db/cli";
import { dimahS3Db } from "@/lib/dimah-s3-db";
await runCli(dimahS3Db);The shipped tables, models, and SQL files already include these indexes:
| Index | Table | Columns |
|---|---|---|
storage_object_scope_folder_idx | storage_object | scope, status, folder, created_at |
storage_object_scope_updated_idx | storage_object | scope, status, updated_at |
storage_object_scope_filename_idx | storage_object | scope, status, filename |
storage_object_scope_size_idx | storage_object | scope, status, size |
storage_object_status_expires_idx | storage_object | status, expires_at |
storage_folder_scope_parent_idx | storage_folder | scope, parent |