2.0 beta — new releases can include breaking changes. Stable 1.x docs
dimah-s3v2.0.0-beta.0
Database

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@beta

You 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:

db/schema.ts
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 push

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

lib/dimah-s3-db.ts
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):

lib/s3.ts
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.

  • getSession runs at most once per Request.
  • resolveScope runs once per call, operation, and route. Two s3.api calls that share a Request but pass different locals resolve separately.

Prop

Type

operationRuns for
uploadpresign, multipart init, and s3.put
confirmconfirm, and the confirm step of multipart complete
multipartpart, list, abort, and complete
downloadpresigned download and GET /file
deletedelete, and each key of deleteMany
listGET /db/objects
readGET /db/object
updatePATCH /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.

lib/s3.ts
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: … }:

lib/s3.ts
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:

  1. S3: DeleteObject removes the object.
  2. Database: deleteMode decides (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.

deleteModeS3 objectDatabase row
"soft"permanently deletedkept as status: "deleted" with deletedAt set
"hard"permanently deletedrow 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

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.

scripts/db-cli.ts
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:

IndexTableColumns
storage_object_scope_folder_idxstorage_objectscope, status, folder, created_at
storage_object_scope_updated_idxstorage_objectscope, status, updated_at
storage_object_scope_filename_idxstorage_objectscope, status, filename
storage_object_scope_size_idxstorage_objectscope, status, size
storage_object_status_expires_idxstorage_objectstatus, expires_at
storage_folder_scope_parent_idxstorage_folderscope, parent

On this page