ReboFilesDocs
Start3
What is ReboFiles
Getting started
Demos to run
Put it in your app6
React component
Headless: your own UI
Without a framework
Styling and themes
Keyboard and accessibility
Languages
Files9
Uploads
Previews and the viewer
Search
Live changes and large folders
Comments
Starred and fields
Versions, trash and space
Download as .zip
Word documents
The server14
The route
Frameworks and sign-in
Who may do what
Storage: S3, GCS, Azure and disk
Checking uploads
Metadata: SQLite and Postgres
Database schema
Sharing and links
Activity and notifications
Hooks, logs and limits
Retention, holds and export
Desktop and other origins
Sync a folder
Going to production
Together1
AI assistants (MCP)
Reference5
Packages
Components and hooks
Client API
HTTP routes
Status and roadmap
↑↓ to move↵ to openEsc to close38 pages

The server Database schema

Database schema

The bytes are in the bucket; everything else is in a database: folders, names, owners, versions, shares, links, the trash, settings, activity and the text of files. The store makes its tables the first time it starts, so there is nothing to run by hand: this page is for knowing what is there.

tip: five tables, nothing to migrate by hand — and models for Prisma

Overview

The tables

Five, named after the table of the entries: rebofiles_entries unless postgresMetadata is given { table }. sqliteMetadata makes the same in one file, named rebofiles_*, the text in a table of SQLite’s full-text search (FTS5).

TableA row isWritten when
rebofiles_entriesA folder or a file, whole, as JSONEvery change, only the rows it touched
…_settingsA setting by its name, as JSONSettings are saved; a link with a password is made; Shared with me is looked at
…_revisionOne row: a numberIt goes up with every change, in the same statement
…_activityA line of what was doneAfter every change, a line for each thing it did
…_textWhat is written in a fileA file is uploaded and the page read its text
Postgres

On Postgres

As postgresMetadata makes them, under a lock of the database so two processes starting at once do not both try.

postgresMetadata(pool)
-- The entries: one row each, the whole entry as JSON.
CREATE TABLE rebofiles_entries (
  id     text PRIMARY KEY,
  parent text,                 -- the folder it is in; null at the top
  entry  jsonb NOT NULL        -- the entry itself: see "An entry, as it is kept"
);
CREATE INDEX rebofiles_entries_parent ON rebofiles_entries (parent);

-- Settings by name: the drive's, each person's, the passwords of links.
CREATE TABLE rebofiles_entries_settings (
  name  text PRIMARY KEY,
  value jsonb NOT NULL
);

-- One row, one number, one more at every change by any process.
CREATE TABLE rebofiles_entries_revision (
  id int PRIMARY KEY CHECK (id = 1),
  n  bigint NOT NULL
);
INSERT INTO rebofiles_entries_revision (id, n) VALUES (1, 0) ON CONFLICT (id) DO NOTHING;

-- What was done: a line each, found by the entry and the folders it is about.
CREATE TABLE rebofiles_entries_activity (
  seq    bigserial PRIMARY KEY,
  entry  text NOT NULL,        -- what it was done to
  parent text,                 -- the folder it is in (for one moved, the one it went to)
  origin text,                 -- for one moved, the folder it left
  line   jsonb NOT NULL
);
CREATE INDEX rebofiles_entries_activity_entry  ON rebofiles_entries_activity (entry, seq);
CREATE INDEX rebofiles_entries_activity_parent ON rebofiles_entries_activity (parent, seq);
CREATE INDEX rebofiles_entries_activity_origin ON rebofiles_entries_activity (origin, seq);

-- What is written in each file, to find it by: the version it is of, and its words.
CREATE TABLE rebofiles_entries_text (
  id      text PRIMARY KEY,    -- the file
  version text NOT NULL,       -- the version the text is of; another one's is not found
  body    text NOT NULL,       -- the first 100,000 characters
  words   tsvector NOT NULL    -- to_tsvector('simple', …) of the words, without case or accents
);
CREATE INDEX rebofiles_entries_text_words ON rebofiles_entries_text USING gin (words);
entry

An entry, as it is kept

The column entry is the entry of @rebofiles/core as it is: a file with its versions, shares and links; a folder with its colour.

a file
{
  "kind": "file",
  "id": "5b0c…",
  "name": "contract.docx",
  "parent": "a1f3…",                      // the folder; null at the top
  "owner": "ana",                          // the id identify() gave
  "created": "2026-10-20T09:12:00.000Z",
  "modified": "2026-10-26T16:40:00.000Z",
  "modifiedBy": "bea",
  "type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  "versions": [                            // the newest first; never empty
    {
      "id": "v2…", "key": "5b0c…/v2…",     // where the bytes are in the bucket
      "size": 48213, "at": "2026-10-26T16:40:00.000Z", "by": "bea",
      "sha256": "9f86d0…",                 // to tell the same file uploaded again
      "placeholder": "data:image/jpeg;base64,/9j/4AAQ…",
      "renditions": [                      // its first page, or a picture, made smaller
        { "size": 480, "width": 480, "height": 679, "key": "5b0c…/v2…@480", "type": "image/jpeg", "bytes": 31877 }
      ]
    },
    { "id": "v1…", "key": "5b0c…/v1…", "size": 47990, "at": "2026-10-20T09:12:00.000Z", "by": "ana" }
  ],
  "shares": [{ "with": "bea", "role": "editor", "by": "ana", "at": "2026-10-21T10:00:00.000Z" }],
  "links": [
    { "id": "l9…", "role": "viewer", "expires": "2026-11-02T00:00:00.000Z", "locked": true, "uses": 1, "used": 0, "by": "ana", "at": "2026-10-26T17:00:00.000Z" }
  ]
}
a folder
{
  "kind": "folder", "id": "a1f3…", "name": "Clients", "parent": null, "color": "teal",
  "owner": "ana", "created": "…", "modified": "…", "modifiedBy": "ana",
  "trashed": { "at": "2026-10-26T18:00:00.000Z", "by": "ana" }   // only on what was put in the trash itself
}
  • id and parent are also columns, for the index of what is in a folder; the rest is only in the JSON.
  • The key of a version is its file’s id and its own, never a path: renaming and moving never touch the bucket.
  • What is inside a folder in the trash is in the trash through it: only the folder has trashed. Shares are the same: what is inside a shared folder is shared through it.
  • A link’s password is not here: locked: true says it has one, and the settings keep it.
Metadata

The fields of a file

The metadata of each file and folder is the column entry of rebofiles_entries: there is no other table of files. Every field of it:

FieldTypeWhat it is
kind'file' | 'folder'What it is.
idstringIts id, a UUID unless newId says otherwise; also the column id.
namestringAs the person reads it, extension included; never twice in a folder, whatever the case or the accents.
parentstring | nullThe folder it is in; null at the top. Also the column parent.
ownerstringThe id identify() gave whoever made it; at the top, whose My files it is in.
createdISO 8601When it was made or first uploaded.
modified, modifiedByISO 8601, stringIts last change of name, place or bytes, and by whom. A colour or a share does not count.
typestringOf a file: its media type, as the upload said it (application/pdf, image/png).
versionsVersion[]Of a file: its versions, the newest first, never empty. versions[0] is the file as it is now.
colorFolderColorOf a folder: red, orange, yellow, green, teal, blue, purple, pink or gray; left out for the drive’s own.
trashed{ at, by }Only on what was put in the trash itself; what is inside it is in the trash through it.
sharesShare[]Who else it is shared with: { with, role, by, at }, with "*" for everyone signed in.
linksLink[]Its links: { id, role, expires, locked?, uses?, used?, spent?, by, at }.

And of each of its versions:

FieldTypeWhat it is
idstringIts id.
keystringWhere its bytes are in the bucket: the file’s id and its own, "<file>/<version>". Never a path.
sizenumberIn bytes, as the server checked them when the upload finished.
at, byISO 8601, stringWhen it was uploaded, and by whom.
sha256stringThe SHA-256 of its bytes in hex, as the page said it (files of up to 64 MiB): to tell the same file uploaded again.
placeholderstringOf a picture or a Word document: a data: URL of a few hundred bytes, shown blurred while it comes.
renditionsRendition[]Smaller copies: { size, width, height, key, type, bytes }, the key "<file>/<version>@<size>".
restoredFromstringOf one made by bringing back an old version: which.
notestringA word on it, when one was given.

Not kept, yet: the width and height of the original picture (only of its smaller copies), what a camera writes in a picture (EXIF), the length of a video or a song, and the pages of a document. A table of your own beside it can hold them, by the id of the file.

settings

Settings, by name

NameValue
drive{ language, theme, brand, images: { enabled, sizes, format, quality }, quota, trashDays, company, requestTemplates }: the drive’s, as whoever may configure it saved them.
person:<id>{ language, theme }: one person’s own, over the drive’s.
link:<link id>"salt:hash": the password of a link, as scrypt makes it (32 bytes, a salt of 16), never the password.
seen-shared:<id>When the person last looked at Shared with me, as ISO 8601: what is newer is new.
activity

A line of activity

The column line; entry, parent and origin are beside it, to find the lines of a file and of what is in a folder.

{
  "at": "2026-10-26T16:41:00.000Z", "by": "bea",
  "action": "renamed",                  // created, uploaded, version, renamed, moved, trashed, restored,
                                        // deleted, shared, unshared, linked, unlinked, colored
  "entry": "5b0c…", "name": "contract-final.docx", "kind": "file",
  "parent": "a1f3…",
  "was": "contract.docx"                // for one renamed; "from" for one moved; "with", "role" for one shared
}
Design

Why it is laid out so

  • One row, one entry. The drive is read whole once and held in memory, read again only when the revision says it changed, so the database is asked by id, never joined: a listing of 20,000 entries is under a millisecond.
  • Only what changed is written. A rename writes one row; moving a folder writes the folder, not what is in it, as its children name it by id.
  • Whole or not at all. On Postgres a change is one statement: the rows removed, the rows put and the revision, together. With a pool, each change also holds pg_advisory_xact_lock, so two servers never change the drive at once.
  • No foreign keys. The model checks the tree (a folder never inside itself, a name never twice in a folder) before anything is written; the database only keeps it.
  • Fine for a team and well beyond; not for millions of entries, which would need the store asked by folder.
Prisma

With Prisma ORM

For an application that keeps its own tables with Prisma 7: the drive’s, as models of its schema.prisma, to read them with Prisma and to keep its migrations from touching them.

prisma/schema.prisma
// What `prisma db pull` reads from the tables ReboFiles made, named as an application names its models.
// `prisma migrate diff` between it and the database: an empty migration.

/// A folder or a file of the drive: the whole entry, as @rebofiles/core has it, in `entry`.
model DriveEntry {
  id     String  @id
  parent String?
  entry  Json

  @@index([parent], map: "rebofiles_entries_parent")
  @@map("rebofiles_entries")
}

/// The drive's settings, each person's, the passwords of links: by name.
model DriveSetting {
  name  String @id
  value Json

  @@map("rebofiles_entries_settings")
}

/// One row, one number: one more at every change. The table has CHECK (id = 1), which Prisma does not model.
model DriveRevision {
  id Int    @id
  n  BigInt

  @@map("rebofiles_entries_revision")
}

/// What was done, a line each.
model DriveActivity {
  seq    BigInt  @id @default(autoincrement())
  entry  String
  parent String?
  origin String?
  line   Json

  @@index([entry, seq], map: "rebofiles_entries_activity_entry")
  @@index([parent, seq], map: "rebofiles_entries_activity_parent")
  @@index([origin, seq], map: "rebofiles_entries_activity_origin")
  @@map("rebofiles_entries_activity")
}

/// What is written in each file, to find it by.
model DriveText {
  id      String                  @id
  version String
  body    String
  words   Unsupported("tsvector")

  @@index([words], map: "rebofiles_entries_text_words", type: Gin)
  @@map("rebofiles_entries_text")
}
  • @@map keeps the tables’ own names; with postgresMetadata(pool, { table: 'acme_files' }), map to those instead.
  • words is a tsvector, which Prisma reads as Unsupported: the model of the text is read in SQL, and written only by the drive.
  • prisma db pull warns of the CHECK (id = 1) of the revision: it stays in the table, Prisma only does not model it.
Prisma

One pool for Prisma and the drive

Prisma 7 talks to Postgres through @prisma/adapter-pg, which takes a pg.Pool: give the same one to postgresMetadata.

// lib/db.ts: one pool, for your Prisma and for the drive.
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
import { PrismaClient } from '@/lib/generated/prisma/client';

export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });

// app/api/files/[...path]/route.ts
import { byRoles, createDriveHandler, postgresMetadata, s3OptionsFrom, s3Storage } from '@rebofiles/server';
import { pool } from '@/lib/db';

const handle = createDriveHandler({
  storage: s3Storage(s3OptionsFrom(process.env)),
  metadata: await postgresMetadata(pool), // a pool: each change holds a lock of the database
  identify, authorize: byRoles, secret: process.env.REBOFILES_SECRET!, base: '/api/files',
});
Prisma

Migrations with Prisma

Prisma Migrate takes tables it does not know for drift, and offers to reset the database, everything in it gone: put the drive’s tables in the schema, and mark them as made.

# The tables are there already, made by ReboFiles: add the models, then tell Prisma they exist.
npx prisma db pull                      # or paste the models above into schema.prisma
mkdir -p prisma/migrations/0_rebofiles
npx prisma migrate diff --from-empty --to-schema prisma/schema.prisma --script > prisma/migrations/0_rebofiles/migration.sql
npx prisma migrate resolve --applied 0_rebofiles

# From then on, migrate dev leaves them as they are: its next migration about them is empty.
npx prisma migrate dev
# A new database: your migrations make the tables first, ReboFiles finds them when it starts.
npx prisma migrate deploy

Either way round works. ReboFiles makes its tables with IF NOT EXISTS and its one row of the revision with ON CONFLICT DO NOTHING: tables Prisma made first are taken as they are.

Prisma

Queries with Prisma

Read the drive with Prisma’s client and JSON paths, and in SQL for what they cannot say. Change it through the drive’s routes, so the revision goes up and the tree stays whole.

import { Prisma } from '@/lib/generated/prisma/client';
import type { Entry } from '@rebofiles/core';
import { prisma } from '@/lib/db';

// Ana's files and folders.
const anas = await prisma.driveEntry.findMany({ where: { entry: { path: ['owner'], equals: 'ana' } } });
const names = anas.map((row) => (row.entry as unknown as Entry).name);

// What is in a folder; the folders at the top.
const inside = await prisma.driveEntry.findMany({ where: { parent: folderId } });
const top = await prisma.driveEntry.findMany({ where: { parent: null, entry: { path: ['kind'], equals: 'folder' } } });

// What was done to it, and in it.
const lines = await prisma.driveActivity.findMany({
  where: { OR: [{ entry: folderId }, { parent: folderId }] },
  orderBy: { seq: 'desc' },
  take: 20,
});

// A person's own settings.
const mine = await prisma.driveSetting.findUnique({ where: { name: 'person:ana' } });

// The bytes each person keeps: what JSON paths cannot say, in SQL.
const bytes = await prisma.$queryRaw<{ owner: string; bytes: number }[]>(Prisma.sql`
  SELECT entry->>'owner' AS owner, sum((v->>'size')::bigint)::float8 AS bytes
  FROM rebofiles_entries, jsonb_array_elements(entry->'versions') v
  GROUP BY 1`);

// Files by what is written in them: words is Unsupported("tsvector"), so in SQL too.
const found = await prisma.$queryRaw<{ id: string; body: string }[]>(Prisma.sql`
  SELECT id, body FROM rebofiles_entries_text WHERE words @@ to_tsquery('simple', ${'payment & 30'})`);
SQL

Queries of your own

For a report, an export or a job: read it as you like. Change it through the server or @rebofiles/core, so the revision goes up and the tree stays whole.

-- Each person's files, and the bytes they keep (every version counts, as the quota counts them).
SELECT entry->>'owner' AS owner,
       count(*) AS files,
       sum((SELECT sum((v->>'size')::bigint) FROM jsonb_array_elements(entry->'versions') v)) AS bytes
FROM rebofiles_entries
WHERE entry->>'kind' = 'file'
GROUP BY 1 ORDER BY bytes DESC;

-- What is shared with Bea, directly.
SELECT entry->>'name', s->>'role'
FROM rebofiles_entries, jsonb_array_elements(entry->'shares') s
WHERE s->>'with' IN ('bea', '*');

-- What was put in the trash before a date.
SELECT id, entry->>'name' FROM rebofiles_entries WHERE (entry->'trashed'->>'at') < '2026-10-01';

-- What Bea did this week.
SELECT line FROM rebofiles_entries_activity WHERE line->>'by' = 'bea' AND line->>'at' > '2026-10-20' ORDER BY seq DESC;

-- An index for a query you make often, as for the owner:
CREATE INDEX rebofiles_entries_owner ON rebofiles_entries ((entry->>'owner'));

Writing to the tables by hand is not seen by a process that holds the drive until the revision goes up: bump it in the same transaction, or change the drive through the routes.

Migrations

Making it yourself

  • The user the server connects with needs to create tables the first time, and to read and write them after. To make them yourself, with your own migrations, run the DDL above first: IF NOT EXISTS leaves them as they are.
  • postgresMetadata(pool, { table: 'acme_files' }) names them acme_files, acme_files_settings… beside your application’s own tables, in its schema.
  • A new version of ReboFiles that needs a table or a column more makes it the same way, at its first start.
MetadataStore

A store of your own

Another database, or tables of your own laid out otherwise: two methods, and the rest optional.

import type { MetadataStore } from '@rebofiles/server';

export const myStore: MetadataStore = {
  async load() { /* every entry, as driveOf(entries) */ },
  async save({ put, removed }) { /* upsert put by id, delete removed: in one transaction */ },
  // Optional; without them the server keeps each in the process.
  async revision() { /* a value that changes with every save, by any process */ },
  async lock(work) { /* work() while no other process saves */ },
  async getSetting(name) {}, async setSetting(name, value) {},
  async record(lines) {}, async activity(id, limit) {},
  async keepText(id, version, text) {}, async findText(query, limit) {},
};

save is given the entries put and the ids removed by each change, from changes(before, after) of core. Metadata says what each store is for.