ReboFilesDocs
Empezar3
Qué es ReboFiles
Primeros pasos
Demos para ejecutar
Ponlo en tu app6
Componente de React
Headless: tu propia interfaz
Sin framework
Estilos y temas
Teclado y accesibilidad
Idiomas
Archivos9
Subidas
Vistas previas y el visor
Búsqueda
Cambios en vivo y carpetas grandes
Comentarios
Destacados y campos
Versiones, papelera y espacio
Descargar como .zip
Documentos de Word
El servidor14
La ruta
Frameworks e inicio de sesión
Quién puede hacer qué
Almacenamiento: S3, GCS, Azure y disco
Revisar las subidas
Metadatos: SQLite y Postgres
Esquema de la base de datos
Compartir y enlaces
Actividad y notificaciones
Ganchos, registros y límites
Retención, bloqueos y exportación
Escritorio y otros orígenes
Sincronizar una carpeta
Ir a producción
En equipo1
Asistentes de IA (MCP)
Referencia5
Paquetes
Componentes y hooks
API del cliente
Rutas HTTP
Estado y hoja de ruta
↑↓ para moverte↵ para abrirEsc para cerrar38 páginas

El servidor Esquema de la base de datos

Esquema de la base de datos

Los bytes están en el bucket; todo lo demás está en una base de datos: carpetas, nombres, propietarios, versiones, comparticiones, enlaces, la papelera, ajustes, actividad y el texto de los archivos. El almacén crea sus tablas la primera vez que arranca, así que no hay nada que ejecutar a mano: esta página es para saber qué hay.

consejo: cinco tablas, nada que migrar a mano, y modelos para Prisma

Resumen

Las tablas

Cinco, con el nombre de la tabla de entradas: rebofiles_entries salvo que postgresMetadata reciba { table }. sqliteMetadata crea las mismas en un archivo, llamadas rebofiles_*, con el texto en una tabla de búsqueda de texto completo de SQLite (FTS5).

TablaUna fila esSe escribe cuando
rebofiles_entriesUna carpeta o un archivo, entero, como JSONCada cambio, solo las filas que toca
…_settingsUn ajuste por su nombre, como JSONSe guardan ajustes; se crea un enlace con contraseña; se mira Compartido conmigo
…_revisionUna fila: un númeroSube con cada cambio, en la misma sentencia
…_activityUna línea de lo que se hizoTras cada cambio, una línea por cada cosa que hizo
…_textLo que está escrito en un archivoSe sube un archivo y la página leyó su texto
Postgres

En Postgres

Tal como las crea postgresMetadata, bajo un bloqueo de la base de datos para que dos procesos que arrancan a la vez no lo intenten ambos.

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

Una entrada, tal como se guarda

La columna entry es la entrada de @rebofiles/core tal cual: un archivo con sus versiones, comparticiones y enlaces; una carpeta con su color.

un archivo
{
  "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" }
  ]
}
una carpeta
{
  "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 y parent también son columnas, para el índice de lo que hay en una carpeta; el resto solo está en el JSON.
  • La clave de una versión es el id de su archivo y el suyo, nunca una ruta: renombrar y mover nunca tocan el bucket.
  • Lo que hay dentro de una carpeta en la papelera está en la papelera a través de ella: solo la carpeta tiene trashed. Con las comparticiones pasa lo mismo: lo que hay dentro de una carpeta compartida se comparte a través de ella.
  • La contraseña de un enlace no está aquí: locked: true dice que tiene una, y la guardan los ajustes.
Metadatos

Los campos de un archivo

Los metadatos de cada archivo y carpeta son la columna entry de rebofiles_entries: no hay otra tabla de archivos. Todos sus campos:

CampoTipoQué es
kind'file' | 'folder'Qué es.
idstringSu id, un UUID salvo que newId diga otra cosa; también la columna id.
namestringComo lo lee la persona, con la extensión; nunca dos veces en una carpeta, sin importar mayúsculas ni acentos.
parentstring | nullLa carpeta en la que está; null arriba del todo. También la columna parent.
ownerstringEl id que identify() dio a quien lo creó; arriba del todo, en los Mis archivos de quién está.
createdISO 8601Cuándo se creó o se subió por primera vez.
modified, modifiedByISO 8601, stringSu último cambio de nombre, de lugar o de bytes, y de quién. Un color o una compartición no cuentan.
typestringDe un archivo: su tipo de medio, como lo dijo la subida (application/pdf, image/png).
versionsVersion[]De un archivo: sus versiones, la más nueva primero, nunca vacío. versions[0] es el archivo tal como está ahora.
colorFolderColorDe una carpeta: red, orange, yellow, green, teal, blue, purple, pink o gray; se omite para el propio del drive.
trashed{ at, by }Solo en lo que se mandó a la papelera directamente; lo que tiene dentro está en la papelera a través de ello.
sharesShare[]Con quién más se comparte: { with, role, by, at }, con "*" para todos los que tienen sesión.
linksLink[]Sus enlaces: { id, role, expires, locked?, uses?, used?, spent?, by, at }.

Y de cada una de sus versiones:

CampoTipoQué es
idstringSu id.
keystringDónde están sus bytes en el bucket: el id del archivo y el suyo, "<file>/<version>". Nunca una ruta.
sizenumberEn bytes, como los comprobó el servidor al terminar la subida.
at, byISO 8601, stringCuándo se subió, y quién.
sha256stringEl SHA-256 de sus bytes en hex, como lo dijo la página (archivos de hasta 64 MiB): para reconocer el mismo archivo subido otra vez.
placeholderstringDe una imagen o un documento de Word: una URL data: de unos cientos de bytes, mostrada difuminada mientras llega.
renditionsRendition[]Copias más pequeñas: { size, width, height, key, type, bytes }, con la clave "<file>/<version>@<size>".
restoredFromstringDe una hecha al recuperar una versión antigua: cuál.
notestringUna nota sobre ella, si se dio.

Aún no se guarda: el ancho y el alto de la imagen original (solo los de sus copias reducidas), lo que una cámara escribe en una foto (EXIF), la duración de un vídeo o una canción, y las páginas de un documento. Una tabla propia al lado puede guardarlos, por el id del archivo.

settings

Ajustes, por nombre

NombreValor
drive{ language, theme, brand, images: { enabled, sizes, format, quality }, quota, trashDays, company, requestTemplates }: los del drive, tal como los guardó quien puede configurarlo.
person:<id>{ language, theme }: los propios de una persona, por encima de los del drive.
link:<link id>"salt:hash": la contraseña de un enlace, tal como la hace scrypt (32 bytes, una sal de 16), nunca la contraseña.
seen-shared:<id>Cuándo miró la persona Compartido conmigo por última vez, en ISO 8601: lo más reciente es nuevo.
activity

Una línea de actividad

La columna line; entry, parent y origin van al lado, para encontrar las líneas de un archivo y de lo que hay en una carpeta.

{
  "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
}
Diseño

Por qué está organizado así

  • Una fila, una entrada. El drive se lee entero una vez y se guarda en memoria, y solo se vuelve a leer cuando la revisión dice que cambió, así que a la base de datos se le pregunta por id, nunca con joins: un listado de 20 000 entradas tarda menos de un milisegundo.
  • Solo se escribe lo que cambió. Renombrar escribe una fila; mover una carpeta escribe la carpeta, no lo que contiene, porque sus hijos la nombran por id.
  • Todo o nada. En Postgres un cambio es una sola sentencia: las filas quitadas, las puestas y la revisión, juntas. Con un pool, cada cambio también mantiene pg_advisory_xact_lock, así que dos servidores nunca cambian el drive a la vez.
  • Sin claves foráneas. El modelo comprueba el árbol (una carpeta nunca dentro de sí misma, un nombre nunca dos veces en una carpeta) antes de escribir nada; la base de datos solo lo guarda.
  • Suficiente para un equipo y mucho más; no para millones de entradas, que necesitarían preguntar al almacén por carpeta.
Prisma

Con Prisma ORM

Para una aplicación que lleva sus propias tablas con Prisma 7: las del drive, como modelos de su schema.prisma, para leerlas con Prisma y que sus migraciones no las toquen.

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 conserva los nombres propios de las tablas; con postgresMetadata(pool, { table: 'acme_files' }), mapea a esos en su lugar.
  • words es un tsvector, que Prisma lee como Unsupported: el modelo del texto se lee en SQL, y solo lo escribe el drive.
  • prisma db pull avisa del CHECK (id = 1) de la revisión: sigue en la tabla, solo que Prisma no lo modela.
Prisma

Un solo pool para Prisma y el drive

Prisma 7 habla con Postgres a través de @prisma/adapter-pg, que recibe un pg.Pool: dale el mismo a 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

Migraciones con Prisma

Prisma Migrate toma las tablas que no conoce por drift, y ofrece reiniciar la base de datos, borrándolo todo: pon las tablas del drive en el esquema, y márcalas como creadas.

# 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

Funciona en cualquier orden. ReboFiles crea sus tablas con IF NOT EXISTS y su única fila de la revisión con ON CONFLICT DO NOTHING: las tablas que Prisma creó antes se toman tal como están.

Prisma

Consultas con Prisma

Lee el drive con el cliente de Prisma y rutas JSON, y en SQL para lo que no pueden expresar. Cámbialo a través de las rutas del drive, para que la revisión suba y el árbol siga entero.

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

Consultas propias

Para un informe, una exportación o un job: léelo como quieras. Cámbialo a través del servidor o de @rebofiles/core, para que la revisión suba y el árbol siga entero.

-- 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'));

Escribir en las tablas a mano no lo ve un proceso que tiene el drive en memoria hasta que sube la revisión: súbela en la misma transacción, o cambia el drive a través de las rutas.

Migraciones

Crearlas tú mismo

  • El usuario con el que se conecta el servidor necesita crear tablas la primera vez, y leerlas y escribirlas después. Para crearlas tú, con tus propias migraciones, ejecuta antes el DDL de arriba: IF NOT EXISTS las deja como están.
  • postgresMetadata(pool, { table: 'acme_files' }) las llama acme_files, acme_files_settings… junto a las tablas propias de tu aplicación, en su esquema.
  • Una nueva versión de ReboFiles que necesite una tabla o una columna más la crea igual, en su primer arranque.
MetadataStore

Un almacén propio

Otra base de datos, o tablas propias organizadas de otra forma: dos métodos, y el resto opcional.

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 recibe las entradas puestas y los ids quitados en cada cambio, de changes(before, after) de core. Metadatos explica para qué sirve cada almacén.